fix(ui): collection of UI bug fixes and improvements (#9346)

This commit is contained in:
Alan Buscaglia
2025-12-03 14:31:23 +01:00
committed by GitHub
parent a4e12a94f9
commit c1bb51cf1a
25 changed files with 958 additions and 623 deletions

View File

@@ -65,7 +65,7 @@ export const PasswordRequirementsMessage = ({
{allRequirementsMet ? (
<div className="flex items-center gap-2">
<CheckCircle
className="text-text-success h-4 w-4 shrink-0"
className="text-text-success-primary h-4 w-4 shrink-0"
aria-hidden="true"
/>
<p className="text-text-neutral-primary text-xs leading-tight font-medium">
@@ -76,7 +76,7 @@ export const PasswordRequirementsMessage = ({
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<AlertCircle
className="text-text-error h-4 w-4 shrink-0"
className="text-text-error-primary h-4 w-4 shrink-0"
aria-hidden="true"
/>
<p className="text-text-neutral-primary text-xs leading-tight font-medium">
@@ -95,12 +95,12 @@ export const PasswordRequirementsMessage = ({
<div className="flex items-center gap-2">
<div
className={`h-2 w-2 shrink-0 rounded-full ${
req.isMet ? "bg-text-success" : "bg-text-error"
req.isMet ? "bg-bg-pass" : "bg-bg-fail"
}`}
aria-hidden="true"
/>
<span
className="text-text-success-primary"
className="text-text-neutral-secondary"
aria-label={`${req.label} ${req.isMet ? "satisfied" : "required"}`}
>
{req.label}

View File

@@ -57,7 +57,6 @@ export const ComplianceHeader = ({
labelCheckboxGroup: "Regions",
values: uniqueRegions,
index: 1, // Show after framework filters
defaultToSelectAll: true, // Default to all regions selected
},
]
: [];

View File

@@ -0,0 +1,35 @@
"use client";
import { X } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { Badge } from "@/components/shadcn";
import { useUrlFilters } from "@/hooks/use-url-filters";
export const ActiveCheckIdFilter = () => {
const searchParams = useSearchParams();
const { clearFilter } = useUrlFilters();
const checkIdFilter = searchParams.get("filter[check_id__in]");
if (!checkIdFilter) {
return null;
}
const checkIds = checkIdFilter.split(",");
const displayText =
checkIds.length > 1
? `${checkIds.length} Check IDs filtered`
: `Check ID: ${checkIds[0]}`;
return (
<Badge
variant="outline"
className="flex cursor-pointer items-center gap-1 px-3 py-1.5"
onClick={() => clearFilter("check_id__in")}
>
<span className="max-w-[200px] truncate text-sm">{displayText}</span>
<X className="size-3.5 shrink-0" />
</Badge>
);
};

View File

@@ -1,3 +1,4 @@
export * from "./active-check-id-filter";
export * from "./clear-filters-button";
export * from "./custom-account-selection";
export * from "./custom-checkbox-muted-findings";

View File

@@ -265,7 +265,7 @@ export const FindingDetail = ({
)}
<InfoField label="Categories">
{attributes.check_metadata.categories?.join(", ") || "-"}
{attributes.check_metadata.categories?.join(", ") || "none"}
</InfoField>
</CardContent>
</Card>

View File

@@ -132,7 +132,9 @@ export function HorizontalBarChart({
<p className="text-text-neutral-primary text-xs leading-5 font-medium">
{item.value.toLocaleString()}{" "}
{item.name === "Informational" ? "Info" : item.name}{" "}
Risk
{item.name === "Fail" || item.name === "Pass"
? "Findings"
: "Risk"}
</p>
</div>

View File

@@ -1,5 +1,6 @@
"use client";
import { Info } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { Rectangle, ResponsiveContainer, Sankey, Tooltip } from "recharts";
@@ -466,6 +467,8 @@ export function SankeyChart({
mapProviderFiltersForFindings(params);
params.set("filter[severity__in]", severityFilter);
params.set("filter[status__in]", "FAIL");
params.set("filter[muted]", "false");
router.push(`/findings?${params.toString()}`);
}
};
@@ -474,13 +477,21 @@ export function SankeyChart({
const providerType = PROVIDER_TYPE_MAP[sourceName];
const severityFilter = SEVERITY_FILTER_MAP[targetName];
if (providerType && severityFilter) {
if (severityFilter) {
const params = new URLSearchParams(searchParams.toString());
mapProviderFiltersForFindings(params);
params.set("filter[provider_type__in]", providerType);
// Always set provider_type filter based on the clicked link's source (provider)
// This ensures clicking "AWS → High" filters by AWS even when no global filter is set
const hasProviderIdFilter = searchParams.has("filter[provider_id__in]");
if (providerType && !hasProviderIdFilter) {
params.set("filter[provider_type__in]", providerType);
}
params.set("filter[severity__in]", severityFilter);
params.set("filter[status__in]", "FAIL");
params.set("filter[muted]", "false");
router.push(`/findings?${params.toString()}`);
}
};
@@ -524,6 +535,25 @@ export function SankeyChart({
/>
);
// Check if there's actual data to display (links with values > 0)
const hasData = data.links.some((link) => link.value > 0);
if (!hasData) {
return (
<div
className="flex items-center justify-center"
style={{ height: `${height}px` }}
>
<div className="flex flex-col items-center gap-2 text-center">
<Info size={48} className="text-text-neutral-tertiary" />
<p className="text-text-neutral-secondary text-sm">
No failed findings to display
</p>
</div>
</div>
);
}
return (
<div className="relative">
<ResponsiveContainer width="100%" height={height}>

View File

@@ -1,6 +1,6 @@
"use client";
import * as d3 from "d3";
import { geoPath } from "d3";
import type {
Feature,
FeatureCollection,
@@ -10,154 +10,28 @@ import type {
import { AlertTriangle, ChevronDown, Info, MapPin } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { feature } from "topojson-client";
import type {
GeometryCollection,
Objects,
Topology,
} from "topojson-specification";
import { Card } from "@/components/shadcn/card/card";
import { mapProviderFiltersForFindings } from "@/lib/provider-helpers";
import { HorizontalBarChart } from "./horizontal-bar-chart";
import {
DEFAULT_MAP_COLORS,
LocationPoint,
MAP_CONFIG,
MapColorsConfig,
STATUS_FILTER_MAP,
ThreatMapProps,
} from "./threat-map.types";
import {
createProjection,
createSVGElement,
fetchWorldData,
getMapColors,
} from "./threat-map.utils";
import { BarDataPoint } from "./types";
// Constants
const MAP_CONFIG = {
defaultWidth: 688,
defaultHeight: 400,
pointRadius: 6,
selectedPointRadius: 8,
transitionDuration: 300,
} as const;
// SVG-specific colors: must use actual color values, not Tailwind classes
// as SVG fill/stroke attributes don't support class-based styling
// Retrieves computed CSS variable values from globals.css theme variables at runtime
// Fallback hex colors are used only when CSS variables cannot be computed (SSR context)
interface MapColorsConfig {
landFill: string;
landStroke: string;
pointDefault: string;
pointSelected: string;
pointHover: string;
}
const DEFAULT_MAP_COLORS: MapColorsConfig = {
// Fallback: gray-300 (neutral-300) - used for map land fill in light theme
landFill: "#d1d5db",
// Fallback: slate-300 - used for map borders
landStroke: "#cbd5e1",
// Fallback: red-600 - error color for points
pointDefault: "#dc2626",
// Fallback: emerald-500 - success color for selected points
pointSelected: "#10b981",
// Fallback: red-600 - error color for hover points
pointHover: "#dc2626",
};
function getMapColors(): MapColorsConfig {
if (typeof document === "undefined") return DEFAULT_MAP_COLORS;
const root = document.documentElement;
const style = getComputedStyle(root);
const getVar = (varName: string): string => {
const value = style.getPropertyValue(varName).trim();
return value && value.length > 0 ? value : "";
};
const colors: MapColorsConfig = {
landFill: getVar("--bg-neutral-map") || DEFAULT_MAP_COLORS.landFill,
landStroke:
getVar("--border-neutral-tertiary") || DEFAULT_MAP_COLORS.landStroke,
pointDefault:
getVar("--text-text-error") || DEFAULT_MAP_COLORS.pointDefault,
pointSelected:
getVar("--bg-button-primary") || DEFAULT_MAP_COLORS.pointSelected,
pointHover: getVar("--text-text-error") || DEFAULT_MAP_COLORS.pointHover,
};
return colors;
}
const RISK_LEVELS = {
LOW_HIGH: "low-high",
HIGH: "high",
CRITICAL: "critical",
} as const;
type RiskLevel = (typeof RISK_LEVELS)[keyof typeof RISK_LEVELS];
interface LocationPoint {
id: string;
name: string;
region: string;
regionCode: string;
providerType: string;
coordinates: [number, number];
totalFindings: number;
riskLevel: RiskLevel;
severityData: BarDataPoint[];
change?: number;
}
interface ThreatMapData {
locations: LocationPoint[];
regions: string[];
}
interface ThreatMapProps {
data: ThreatMapData;
height?: number;
onLocationSelect?: (location: LocationPoint | null) => void;
}
// Utility functions
function createProjection(width: number, height: number) {
return d3
.geoNaturalEarth1()
.fitExtent(
[
[1, 1],
[width - 1, height - 1],
],
{ type: "Sphere" },
)
.precision(0.2);
}
async function fetchWorldData(): Promise<FeatureCollection | null> {
try {
const worldAtlasModule = await import("world-atlas/countries-110m.json");
const worldData = worldAtlasModule.default || worldAtlasModule;
const topology = worldData as unknown as Topology<Objects>;
return feature(
topology,
topology.objects.countries as GeometryCollection,
) as FeatureCollection;
} catch (error) {
console.error("Error loading world map data:", error);
return null;
}
}
// Helper: Create SVG element
function createSVGElement<T extends SVGElement>(
type: string,
attributes: Record<string, string>,
): T {
const element = document.createElementNS(
"http://www.w3.org/2000/svg",
type,
) as T;
Object.entries(attributes).forEach(([key, value]) => {
element.setAttribute(key, value);
});
return element;
}
// Components
// Sub-components
function MapTooltip({
location,
position,
@@ -183,19 +57,13 @@ function MapTooltip({
<div className="mt-1 flex items-center gap-2">
<AlertTriangle size={14} className="text-bg-data-critical" />
<span className="text-text-neutral-secondary text-sm font-medium">
{location.totalFindings.toLocaleString()} Fail Findings
{location.failFindings.toLocaleString()} Fail Findings
</span>
</div>
{location.change !== undefined && (
<p className="text-text-neutral-secondary mt-1 text-sm font-medium">
<span
className="font-bold"
style={{
color:
location.change > 0
? "var(--bg-pass-primary)"
: "var(--bg-fail-primary)",
}}
className={`font-bold ${location.change > 0 ? "text-pass-primary" : "text-fail-primary"}`}
>
{location.change > 0 ? "+" : ""}
{location.change}%{" "}
@@ -207,34 +75,38 @@ function MapTooltip({
);
}
function EmptyState() {
function LocationDetails({
location,
onBarClick,
}: {
location: Pick<LocationPoint, "name" | "totalFindings" | "severityData">;
onBarClick: (dataPoint: BarDataPoint) => void;
}) {
return (
<div className="flex h-full min-h-[400px] w-full items-center justify-center">
<div className="text-center">
<Info size={48} className="mx-auto mb-2 text-slate-500" />
<p className="text-sm text-slate-400">
Select a location on the map to view details
<div className="flex w-full flex-col">
<div className="mb-4">
<div className="mb-1 flex items-center">
<MapPin size={21} className="text-text-error" />
<div
aria-hidden="true"
className="bg-pass-primary h-2 w-2 rounded-full"
/>
<h4 className="text-neutral-primary text-base font-semibold">
{location.name}
</h4>
</div>
<p className="text-neutral-tertiary text-xs">
{location.totalFindings.toLocaleString()} Total Findings
</p>
</div>
<HorizontalBarChart
data={location.severityData}
onBarClick={onBarClick}
/>
</div>
);
}
function LoadingState({ height }: { height: number }) {
return (
<div className="flex items-center justify-center" style={{ height }}>
<div className="text-center">
<div className="mb-2 text-slate-400">Loading map...</div>
</div>
</div>
);
}
const STATUS_FILTER_MAP: Record<string, string> = {
Fail: "FAIL",
Pass: "PASS",
};
export function ThreatMap({
data,
height = MAP_CONFIG.defaultHeight,
@@ -243,6 +115,7 @@ export function ThreatMap({
const searchParams = useSearchParams();
const svgRef = useRef<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [selectedLocation, setSelectedLocation] =
useState<LocationPoint | null>(null);
const [hoveredLocation, setHoveredLocation] = useState<LocationPoint | null>(
@@ -252,7 +125,7 @@ export function ThreatMap({
x: number;
y: number;
} | null>(null);
const [selectedRegion, setSelectedRegion] = useState<string>("All Regions");
const [selectedRegion, setSelectedRegion] = useState("All Regions");
const [worldData, setWorldData] = useState<FeatureCollection | null>(null);
const [isLoadingMap, setIsLoadingMap] = useState(true);
const [dimensions, setDimensions] = useState<{
@@ -265,142 +138,193 @@ export function ThreatMap({
const [mapColors, setMapColors] =
useState<MapColorsConfig>(DEFAULT_MAP_COLORS);
const filteredLocations =
selectedRegion === "All Regions"
? data.locations
: data.locations.filter((loc) => loc.region === selectedRegion);
const isGlobalSelected = selectedRegion.toLowerCase() === "global";
const isAllRegions = selectedRegion === "All Regions";
// Monitor theme changes and update colors
// For display count only (not used in useEffect to avoid infinite loop)
const locationCount = data.locations.filter((loc) => {
if (loc.region.toLowerCase() === "global") return false;
if (isAllRegions) return true;
if (isGlobalSelected) return false;
return loc.region === selectedRegion;
}).length;
const sortedRegions = [...data.regions].sort((a, b) => {
if (a.toLowerCase() === "global") return -1;
if (b.toLowerCase() === "global") return 1;
return a.localeCompare(b);
});
// Compute global aggregated data
const globalLocations = data.locations.filter(
(loc) => loc.region.toLowerCase() === "global",
);
const globalAggregatedData =
globalLocations.length > 0
? (() => {
const aggregate = (name: string) =>
globalLocations.reduce(
(sum, loc) =>
sum +
(loc.severityData.find((d) => d.name === name)?.value || 0),
0,
);
const failValue = aggregate("Fail");
const passValue = aggregate("Pass");
const total = failValue + passValue;
return {
name: "Global Regions",
regionCode: "global",
providerType: "global",
totalFindings: total,
failFindings: failValue,
severityData: [
{
name: "Fail",
value: failValue,
percentage:
total > 0 ? Math.round((failValue / total) * 100) : 0,
color: "var(--color-bg-fail)",
},
{
name: "Pass",
value: passValue,
percentage:
total > 0 ? Math.round((passValue / total) * 100) : 0,
color: "var(--color-bg-pass)",
},
],
};
})()
: null;
// Reset selected location when region changes
useEffect(() => {
const updateColors = () => {
setMapColors(getMapColors());
};
// Update colors immediately
updateColors();
// Watch for theme changes (dark class on document)
const observer = new MutationObserver(() => {
updateColors();
});
setSelectedLocation(null);
}, [selectedRegion]);
// Theme colors
useEffect(() => {
setMapColors(getMapColors());
const observer = new MutationObserver(() => setMapColors(getMapColors()));
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
// Fetch world data once on mount
// Fetch world data
useEffect(() => {
let isMounted = true;
let mounted = true;
fetchWorldData()
.then((data) => {
if (isMounted && data) setWorldData(data);
})
.catch(console.error)
.finally(() => {
if (isMounted) setIsLoadingMap(false);
});
.then((d) => mounted && d && setWorldData(d))
.finally(() => mounted && setIsLoadingMap(false));
return () => {
isMounted = false;
mounted = false;
};
}, []);
// Update dimensions on resize
// Resize handler
useEffect(() => {
const updateDimensions = () => {
if (containerRef.current) {
setDimensions({ width: containerRef.current.clientWidth, height });
}
};
updateDimensions();
window.addEventListener("resize", updateDimensions);
return () => window.removeEventListener("resize", updateDimensions);
const update = () =>
containerRef.current &&
setDimensions({ width: containerRef.current.clientWidth, height });
update();
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, [height]);
// Render the map
// Render map
useEffect(() => {
if (!svgRef.current || !worldData || isLoadingMap) return;
const svg = svgRef.current;
const { width, height } = dimensions;
svg.innerHTML = "";
const projection = createProjection(width, height);
const path = d3.geoPath().projection(projection);
const colors = mapColors;
// Compute filtered locations inside useEffect to avoid infinite loop
const isGlobal = selectedRegion.toLowerCase() === "global";
const isAll = selectedRegion === "All Regions";
const locationsToRender = data.locations.filter((loc) => {
if (loc.region.toLowerCase() === "global") return false;
if (isAll) return true;
if (isGlobal) return false;
return loc.region === selectedRegion;
});
// Render countries
const projection = createProjection(dimensions.width, dimensions.height);
const path = geoPath().projection(projection);
// Countries
const mapGroup = createSVGElement<SVGGElement>("g", {
class: "map-countries",
});
const fillColor = isGlobal ? mapColors.pointDefault : mapColors.landFill;
worldData.features?.forEach(
(feature: Feature<Geometry, GeoJsonProperties>) => {
const pathData = path(feature);
(feat: Feature<Geometry, GeoJsonProperties>) => {
const pathData = path(feat);
if (pathData) {
const pathElement = createSVGElement<SVGPathElement>("path", {
const el = createSVGElement<SVGPathElement>("path", {
d: pathData,
fill: colors.landFill,
stroke: colors.landStroke,
fill: fillColor,
stroke: mapColors.landStroke,
"stroke-width": "0.5",
});
mapGroup.appendChild(pathElement);
mapGroup.appendChild(el);
}
},
);
svg.appendChild(mapGroup);
// Helper to update tooltip position
const updateTooltip = (e: MouseEvent) => {
const rect = svg.getBoundingClientRect();
setTooltipPosition({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
};
// Helper to create glow rings
const createGlowRing = (
cx: string,
cy: string,
radiusOffset: number,
r: number,
color: string,
opacity: string,
): SVGCircleElement => {
return createSVGElement<SVGCircleElement>("circle", {
) =>
createSVGElement<SVGCircleElement>("circle", {
cx,
cy,
r: radiusOffset.toString(),
r: r.toString(),
fill: "none",
stroke: color,
"stroke-width": "1",
opacity,
});
};
// Helper to create circle with glow
const createCircle = (location: LocationPoint) => {
const projected = projection(location.coordinates);
if (!projected) return null;
// Points
const pointsGroup = createSVGElement<SVGGElement>("g", {
class: "threat-points",
});
const [x, y] = projected;
if (x < 0 || x > width || y < 0 || y > height) return null;
const createPoint = (loc: LocationPoint) => {
const proj = projection(loc.coordinates);
if (
!proj ||
proj[0] < 0 ||
proj[0] > dimensions.width ||
proj[1] < 0 ||
proj[1] > dimensions.height
) {
return null;
}
const isSelected = selectedLocation?.id === location.id;
const isHovered = hoveredLocation?.id === location.id;
const [x, y] = proj;
const isSelected = selectedLocation?.id === loc.id;
const radius = isSelected
? MAP_CONFIG.selectedPointRadius
: MAP_CONFIG.pointRadius;
const color = isSelected
? mapColors.pointSelected
: mapColors.pointDefault;
const group = createSVGElement<SVGGElement>("g", {
class: "cursor-pointer",
});
const radius = isSelected
? MAP_CONFIG.selectedPointRadius
: MAP_CONFIG.pointRadius;
const color = isSelected ? colors.pointSelected : colors.pointDefault;
// Add glow rings for all points (unselected and selected)
group.appendChild(
createGlowRing(x.toString(), y.toString(), radius + 4, color, "0.4"),
);
@@ -413,18 +337,27 @@ export function ThreatMap({
cy: y.toString(),
r: radius.toString(),
fill: color,
class: isHovered && !isSelected ? "opacity-70" : "",
});
group.appendChild(circle);
group.addEventListener("click", () =>
setSelectedLocation(isSelected ? null : location),
setSelectedLocation(isSelected ? null : loc),
);
group.addEventListener("mouseenter", (e) => {
setHoveredLocation(location);
updateTooltip(e);
setHoveredLocation(loc);
const rect = svg.getBoundingClientRect();
setTooltipPosition({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
});
group.addEventListener("mousemove", (e) => {
const rect = svg.getBoundingClientRect();
setTooltipPosition({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
});
group.addEventListener("mousemove", updateTooltip);
group.addEventListener("mouseleave", () => {
setHoveredLocation(null);
setTooltipPosition(null);
@@ -433,45 +366,50 @@ export function ThreatMap({
return group;
};
// Render points
const pointsGroup = createSVGElement<SVGGElement>("g", {
class: "threat-points",
});
// Unselected points first
filteredLocations.forEach((location) => {
if (selectedLocation?.id !== location.id) {
const circle = createCircle(location);
if (circle) pointsGroup.appendChild(circle);
locationsToRender.forEach((loc) => {
if (selectedLocation?.id !== loc.id) {
const point = createPoint(loc);
if (point) pointsGroup.appendChild(point);
}
});
// Selected point last (on top)
if (selectedLocation) {
const selectedData = filteredLocations.find(
(loc) => loc.id === selectedLocation.id,
);
if (selectedData) {
const circle = createCircle(selectedData);
if (circle) pointsGroup.appendChild(circle);
const loc = locationsToRender.find((l) => l.id === selectedLocation.id);
if (loc) {
const point = createPoint(loc);
if (point) pointsGroup.appendChild(point);
}
}
svg.appendChild(pointsGroup);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
dimensions,
filteredLocations,
data.locations,
selectedRegion,
selectedLocation,
hoveredLocation,
worldData,
isLoadingMap,
mapColors,
]);
const navigateToFindings = (
status: string,
regionCode: string,
providerType?: string,
) => {
const params = new URLSearchParams(searchParams.toString());
mapProviderFiltersForFindings(params);
if (providerType) params.set("filter[provider_type__in]", providerType);
params.set("filter[region__in]", regionCode);
params.set("filter[status__in]", status);
params.set("filter[muted]", "false");
router.push(`/findings?${params.toString()}`);
};
return (
<div className="flex h-full w-full flex-col gap-4">
<div className="flex flex-1 gap-12 overflow-hidden">
{/* Map Section - in Card */}
<div className="flex basis-[70%] flex-col overflow-hidden">
<Card
ref={containerRef}
@@ -490,7 +428,7 @@ export function ThreatMap({
className="border-border-neutral-primary bg-bg-neutral-secondary text-text-neutral-primary appearance-none rounded-lg border px-4 py-2 pr-10 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
>
<option value="All Regions">All Regions</option>
{data.regions.map((region) => (
{sortedRegions.map((region) => (
<option key={region} value={region}>
{region}
</option>
@@ -505,96 +443,82 @@ export function ThreatMap({
<div className="relative w-full flex-1">
{isLoadingMap ? (
<LoadingState height={dimensions.height} />
) : (
<>
<div className="relative h-full w-full">
<svg
ref={svgRef}
width={dimensions.width}
height={dimensions.height}
className="h-full w-full"
style={{ maxWidth: "100%", maxHeight: "100%" }}
preserveAspectRatio="xMidYMid meet"
/>
{hoveredLocation && tooltipPosition && (
<MapTooltip
location={hoveredLocation}
position={tooltipPosition}
/>
)}
<div
className="border-border-neutral-primary bg-bg-neutral-secondary absolute bottom-4 left-4 flex items-center gap-2 rounded-full border px-3 py-1.5"
role="status"
aria-label={`${filteredLocations.length} threat locations on map`}
>
<div
aria-hidden="true"
className="h-3 w-3 rounded"
style={{ backgroundColor: "var(--bg-data-critical)" }}
/>
<span className="text-text-neutral-primary text-sm font-medium">
{filteredLocations.length} Locations
</span>
</div>
<div
className="flex items-center justify-center"
style={{ height: dimensions.height }}
>
<div className="text-text-neutral-tertiary mb-2">
Loading map...
</div>
</>
</div>
) : (
<div className="relative h-full w-full">
<svg
ref={svgRef}
width={dimensions.width}
height={dimensions.height}
className="h-full w-full"
style={{ maxWidth: "100%", maxHeight: "100%" }}
preserveAspectRatio="xMidYMid meet"
/>
{hoveredLocation && tooltipPosition && (
<MapTooltip
location={hoveredLocation}
position={tooltipPosition}
/>
)}
<div className="border-border-neutral-primary bg-bg-neutral-secondary absolute bottom-4 left-4 flex items-center gap-2 rounded-full border px-3 py-1.5">
<div
aria-hidden="true"
className="bg-data-critical h-3 w-3 rounded"
/>
<span className="text-text-neutral-primary text-sm font-medium">
{locationCount} Locations
</span>
</div>
</div>
)}
</div>
</Card>
</div>
{/* Details Section - No Card */}
<div className="flex basis-[30%] items-center overflow-hidden">
{selectedLocation ? (
<div className="flex w-full flex-col">
<div className="mb-4">
<div
className="mb-1 flex items-center"
aria-label={`Selected location: ${selectedLocation.name}`}
>
<MapPin
size={21}
style={{ color: "var(--color-text-text-error)" }}
/>
<div
aria-hidden="true"
className="bg-pass-primary h-2 w-2 rounded-full"
/>
<h4 className="text-neutral-primary text-base font-semibold">
{selectedLocation.name}
</h4>
</div>
<p className="text-neutral-tertiary text-xs">
{selectedLocation.totalFindings.toLocaleString()} Total
Findings
<LocationDetails
location={selectedLocation}
onBarClick={(dp) => {
const status = STATUS_FILTER_MAP[dp.name];
if (status && selectedLocation.providerType) {
navigateToFindings(
status,
selectedLocation.regionCode,
selectedLocation.providerType,
);
}
}}
/>
) : isGlobalSelected && globalAggregatedData ? (
<LocationDetails
location={globalAggregatedData}
onBarClick={(dp) => {
const status = STATUS_FILTER_MAP[dp.name];
if (status) {
navigateToFindings(status, "global");
}
}}
/>
) : (
<div className="flex h-full min-h-[400px] w-full items-center justify-center">
<div className="text-center">
<Info
size={48}
className="text-text-neutral-secondary mx-auto mb-2"
/>
<p className="text-text-neutral-tertiary text-sm">
Select a location on the map to view details
</p>
</div>
<HorizontalBarChart
data={selectedLocation.severityData}
onBarClick={(dataPoint) => {
const status = STATUS_FILTER_MAP[dataPoint.name];
if (status && selectedLocation.providerType) {
const params = new URLSearchParams(searchParams.toString());
mapProviderFiltersForFindings(params);
params.set(
"filter[provider_type__in]",
selectedLocation.providerType,
);
params.set(
"filter[region__in]",
selectedLocation.regionCode,
);
params.set("filter[status__in]", status);
router.push(`/findings?${params.toString()}`);
}
}}
/>
</div>
) : (
<EmptyState />
)}
</div>
</div>

View File

@@ -0,0 +1,66 @@
import { BarDataPoint } from "./types";
export const MAP_CONFIG = {
defaultWidth: 688,
defaultHeight: 400,
pointRadius: 6,
selectedPointRadius: 8,
transitionDuration: 300,
} as const;
export const RISK_LEVELS = {
LOW_HIGH: "low-high",
HIGH: "high",
CRITICAL: "critical",
} as const;
export type RiskLevel = (typeof RISK_LEVELS)[keyof typeof RISK_LEVELS];
export interface LocationPoint {
id: string;
name: string;
region: string;
regionCode: string;
providerType: string;
coordinates: [number, number];
totalFindings: number;
failFindings: number;
riskLevel: RiskLevel;
severityData: BarDataPoint[];
change?: number;
}
export interface ThreatMapData {
locations: LocationPoint[];
regions: string[];
}
export interface ThreatMapProps {
data: ThreatMapData;
height?: number;
onLocationSelect?: (location: LocationPoint | null) => void;
}
export interface MapColorsConfig {
landFill: string;
landStroke: string;
pointDefault: string;
pointSelected: string;
pointHover: string;
}
// SVG fill/stroke attributes require actual color values, not Tailwind classes
// These hex fallbacks are used during SSR when CSS variables aren't available
// At runtime, getMapColors() retrieves computed CSS variable values
export const DEFAULT_MAP_COLORS: MapColorsConfig = {
landFill: "#d1d5db", // --bg-neutral-map fallback
landStroke: "#cbd5e1", // --border-neutral-tertiary fallback
pointDefault: "#dc2626", // --text-text-error fallback
pointSelected: "#10b981", // --bg-button-primary fallback
pointHover: "#dc2626", // --text-text-error fallback
};
export const STATUS_FILTER_MAP: Record<string, string> = {
Fail: "FAIL",
Pass: "PASS",
};

View File

@@ -0,0 +1,73 @@
import { geoNaturalEarth1 } from "d3";
import type { FeatureCollection } from "geojson";
import { feature } from "topojson-client";
import type {
GeometryCollection,
Objects,
Topology,
} from "topojson-specification";
import { DEFAULT_MAP_COLORS, MapColorsConfig } from "./threat-map.types";
export function createProjection(width: number, height: number) {
return geoNaturalEarth1()
.fitExtent(
[
[1, 1],
[width - 1, height - 1],
],
{ type: "Sphere" },
)
.precision(0.2);
}
export async function fetchWorldData(): Promise<FeatureCollection | null> {
try {
const worldAtlasModule = await import("world-atlas/countries-110m.json");
const worldData = worldAtlasModule.default || worldAtlasModule;
const topology = worldData as unknown as Topology<Objects>;
return feature(
topology,
topology.objects.countries as GeometryCollection,
) as FeatureCollection;
} catch (error) {
console.error("Error loading world map data:", error);
return null;
}
}
export function createSVGElement<T extends SVGElement>(
type: string,
attributes: Record<string, string>,
): T {
const element = document.createElementNS(
"http://www.w3.org/2000/svg",
type,
) as T;
Object.entries(attributes).forEach(([key, value]) => {
element.setAttribute(key, value);
});
return element;
}
export function getMapColors(): MapColorsConfig {
if (typeof document === "undefined") return DEFAULT_MAP_COLORS;
const root = document.documentElement;
const style = getComputedStyle(root);
const getVar = (varName: string): string => {
const value = style.getPropertyValue(varName).trim();
return value && value.length > 0 ? value : "";
};
return {
landFill: getVar("--bg-neutral-map") || DEFAULT_MAP_COLORS.landFill,
landStroke:
getVar("--border-neutral-tertiary") || DEFAULT_MAP_COLORS.landStroke,
pointDefault:
getVar("--text-text-error") || DEFAULT_MAP_COLORS.pointDefault,
pointSelected:
getVar("--bg-button-primary") || DEFAULT_MAP_COLORS.pointSelected,
pointHover: getVar("--text-text-error") || DEFAULT_MAP_COLORS.pointHover,
};
}

View File

@@ -21,6 +21,32 @@ import { createDict } from "@/lib";
import { buildGitFileUrl } from "@/lib/iac-utils";
import { FindingProps, ProviderType, ResourceProps } from "@/types";
const SEVERITY_ORDER = {
critical: 0,
high: 1,
medium: 2,
low: 3,
informational: 4,
} as const;
type SeverityLevel = keyof typeof SEVERITY_ORDER;
interface ResourceFinding {
type: "findings";
id: string;
attributes: {
status: "PASS" | "FAIL" | "MANUAL";
severity: SeverityLevel;
check_metadata?: {
checktitle?: string;
};
};
}
interface FindingReference {
id: string;
}
const renderValue = (value: string | null | undefined) => {
return value && value.trim() !== "" ? value : "-";
};
@@ -57,7 +83,7 @@ export const ResourceDetail = ({
resourceId: string;
initialResourceData: ResourceProps;
}) => {
const [findingsData, setFindingsData] = useState<any[]>([]);
const [findingsData, setFindingsData] = useState<ResourceFinding[]>([]);
const [resourceTags, setResourceTags] = useState<Record<string, string>>({});
const [findingsLoading, setFindingsLoading] = useState(true);
const [selectedFindingId, setSelectedFindingId] = useState<string | null>(
@@ -86,9 +112,9 @@ export const ResourceDetail = ({
const findingsDict = createDict("findings", resourceData);
const findings =
resourceData.data.relationships.findings.data?.map(
(finding: any) => findingsDict[finding.id],
(finding: FindingReference) => findingsDict[finding.id],
) || [];
setFindingsData(findings);
setFindingsData(findings as ResourceFinding[]);
} else {
setFindingsData([]);
}
@@ -162,7 +188,21 @@ export const ResourceDetail = ({
const resource = initialResourceData;
const attributes = resource.attributes;
const providerData = resource.relationships.provider.data.attributes;
const allFindings = findingsData;
// Filter only failed findings and sort by severity
const failedFindings = findingsData
.filter(
(finding: ResourceFinding) => finding?.attributes?.status === "FAIL",
)
.sort((a: ResourceFinding, b: ResourceFinding) => {
const severityA = (a?.attributes?.severity?.toLowerCase() ||
"informational") as SeverityLevel;
const severityB = (b?.attributes?.severity?.toLowerCase() ||
"informational") as SeverityLevel;
return (
(SEVERITY_ORDER[severityA] ?? 999) - (SEVERITY_ORDER[severityB] ?? 999)
);
});
// Build Git URL for IaC resources
const gitUrl =
@@ -273,10 +313,10 @@ export const ResourceDetail = ({
</CardContent>
</Card>
{/* Finding associated with this resource section */}
{/* Failed findings associated with this resource section */}
<Card variant="base" padding="lg">
<CardHeader>
<CardTitle>Findings associated with this resource</CardTitle>
<CardTitle>Failed findings associated with this resource</CardTitle>
</CardHeader>
<CardContent>
{findingsLoading ? (
@@ -286,12 +326,12 @@ export const ResourceDetail = ({
Loading findings...
</p>
</div>
) : allFindings.length > 0 ? (
) : failedFindings.length > 0 ? (
<div className="flex flex-col gap-4">
<p className="dark:text-prowler-theme-pale/80 text-sm text-gray-600">
Total findings: {allFindings.length}
Total failed findings: {failedFindings.length}
</p>
{allFindings.map((finding: any, index: number) => {
{failedFindings.map((finding: ResourceFinding, index: number) => {
const { attributes: findingAttrs, id } = finding;
// Handle cases where finding might not have all attributes
@@ -338,7 +378,7 @@ export const ResourceDetail = ({
</div>
) : (
<p className="dark:text-prowler-theme-pale/80 text-gray-600">
No findings found for this resource.
No failed findings found for this resource.
</p>
)}
</CardContent>

View File

@@ -380,11 +380,9 @@ export function MultiSelectSeparator({
export function MultiSelectSelectAll({
className,
children = "Select All",
allValues = [],
...props
}: Omit<ComponentPropsWithoutRef<"div">, "children"> & {
}: Omit<ComponentPropsWithoutRef<"button">, "children"> & {
children?: ReactNode;
allValues?: string[];
}) {
const { selectedValues, onValuesChange } = useMultiSelectContext();
@@ -392,49 +390,28 @@ export function MultiSelectSelectAll({
return null;
}
const selectedArray = Array.from(selectedValues);
const allSelected =
allValues.length > 0 && selectedArray.length === allValues.length;
const hasSelections = selectedValues.size > 0;
const handleSelectAll = () => {
if (allSelected) {
// Deselect all
onValuesChange?.([]);
} else {
// Select all
onValuesChange?.(allValues);
}
const handleClearAll = () => {
// Clear all selections
onValuesChange?.([]);
};
return (
<div
role="option"
aria-selected={allSelected}
<button
type="button"
data-slot="multiselect-select-all"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary flex w-full cursor-pointer items-center justify-between gap-3 rounded-lg px-4 py-3 text-sm outline-hidden select-none hover:bg-slate-200 dark:hover:bg-slate-700/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
allSelected && "bg-slate-100 dark:bg-slate-800/50",
hasSelections && "text-destructive hover:text-destructive",
"font-semibold",
className,
)}
onClick={handleSelectAll}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSelectAll();
}
}}
tabIndex={0}
onClick={handleClearAll}
{...props}
>
<span className="flex min-w-0 flex-1 items-center gap-2">{children}</span>
<CheckIcon
className={cn(
"text-bg-button-secondary size-5 shrink-0",
allSelected ? "opacity-100" : "opacity-0",
)}
/>
</div>
</button>
);
}

View File

@@ -3,6 +3,7 @@
import { useSearchParams } from "next/navigation";
import { ComplianceScanInfo } from "@/components/compliance/compliance-header/compliance-scan-info";
import { ActiveCheckIdFilter } from "@/components/filters/active-check-id-filter";
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
import {
MultiSelect,
@@ -116,16 +117,25 @@ export const DataTableFilterCustom = ({
updateFilter(filter.key, values.length > 0 ? values : null);
};
const getSelectedValues = (key: string): string[] => {
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
const getSelectedValues = (filter: FilterOption): string[] => {
const filterKey = filter.key.startsWith("filter[")
? filter.key
: `filter[${filter.key}]`;
const paramValue = searchParams.get(filterKey);
// If defaultToSelectAll is true and no filter param exists,
// treat it as "all selected" by returning all values
if (!paramValue && filter.defaultToSelectAll) {
return filter.values;
}
return paramValue ? paramValue.split(",") : [];
};
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
{sortedFilters().map((filter) => {
const selectedValues = getSelectedValues(filter.key);
const selectedValues = getSelectedValues(filter);
return (
<MultiSelect
@@ -137,9 +147,7 @@ export const DataTableFilterCustom = ({
<MultiSelectValue placeholder={filter.labelCheckboxGroup} />
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectSelectAll allValues={filter.values}>
Select All
</MultiSelectSelectAll>
<MultiSelectSelectAll>Select All</MultiSelectSelectAll>
<MultiSelectSeparator />
{filter.values.map((value) => {
const entity = getEntityForValue(filter, value);
@@ -157,7 +165,8 @@ export const DataTableFilterCustom = ({
</MultiSelect>
);
})}
<div className="flex items-center justify-start">
<div className="flex items-center justify-start gap-2">
<ActiveCheckIdFilter />
<ClearFiltersButton />
</div>
</div>

View File

@@ -10,16 +10,15 @@ import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { getPaginationInfo } from "@/lib";
import { MetaDataProps } from "@/types";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../select/Select";
} from "@/components/shadcn/select/select";
import { getPaginationInfo } from "@/lib";
import { MetaDataProps } from "@/types";
interface DataTablePaginationProps {
metadata?: MetaDataProps;
@@ -114,7 +113,7 @@ export function DataTablePagination({
}
}}
>
<SelectTrigger className="h-8 w-18">
<SelectTrigger className="h-8 w-[6rem]">
<SelectValue />
</SelectTrigger>
<SelectContent side="top">

View File

@@ -1,22 +1,33 @@
import { Chip } from "@heroui/chip";
import clsx from "clsx";
import React from "react";
import { SpinnerIcon } from "@/components/icons";
import { cn } from "@/lib/utils";
export type Status =
| "available"
| "queued"
| "scheduled"
| "executing"
| "completed"
| "failed"
| "cancelled";
const statusDisplayMap: Record<Status, string> = {
available: "Queued",
queued: "Queued",
scheduled: "scheduled",
executing: "executing",
completed: "completed",
failed: "failed",
cancelled: "cancelled",
};
const statusColorMap: Record<
Status,
"danger" | "warning" | "success" | "default"
> = {
available: "default",
queued: "default",
scheduled: "warning",
executing: "default",
completed: "success",
@@ -40,7 +51,7 @@ export const StatusBadge = ({
return (
<Chip
className={clsx(
className={cn(
"text-default-600 relative w-full max-w-full border-none text-xs capitalize",
status === "executing" && "border border-solid border-transparent",
className,
@@ -59,7 +70,9 @@ export const StatusBadge = ({
<span>executing</span>
</div>
) : (
<span className="flex items-center justify-center">{status}</span>
<span className="flex items-center justify-center">
{statusDisplayMap[status as keyof typeof statusDisplayMap] || status}
</span>
)}
</Chip>
);