mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 21:11:53 +00:00
fix(ui): collection of UI bug fixes and improvements (#9346)
This commit is contained in:
@@ -18,6 +18,7 @@ export interface AttackSurfaceItem {
|
||||
label: string;
|
||||
failedFindings: number;
|
||||
totalFindings: number;
|
||||
checkIds: string[];
|
||||
}
|
||||
|
||||
const ATTACK_SURFACE_LABELS: Record<AttackSurfaceId, string> = {
|
||||
@@ -41,6 +42,7 @@ function mapAttackSurfaceItem(item: AttackSurfaceOverview): AttackSurfaceItem {
|
||||
label: ATTACK_SURFACE_LABELS[id] || item.id,
|
||||
failedFindings: item.attributes.failed_findings,
|
||||
totalFindings: item.attributes.total_findings,
|
||||
checkIds: item.attributes.check_ids ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { getProviderDisplayName } from "@/types/providers";
|
||||
|
||||
import {
|
||||
FindingsSeverityOverviewResponse,
|
||||
ProviderOverview,
|
||||
ProvidersOverviewResponse,
|
||||
} from "./types";
|
||||
|
||||
export interface SankeyNode {
|
||||
name: string;
|
||||
}
|
||||
@@ -27,44 +21,16 @@ export interface SankeyData {
|
||||
zeroDataProviders: ZeroDataProvider[];
|
||||
}
|
||||
|
||||
export interface SankeyFilters {
|
||||
providerTypes?: string[];
|
||||
/** All selected provider types - used to show missing providers in legend */
|
||||
allSelectedProviderTypes?: string[];
|
||||
export interface SeverityData {
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
informational: number;
|
||||
}
|
||||
|
||||
interface AggregatedProvider {
|
||||
id: string;
|
||||
displayName: string;
|
||||
pass: number;
|
||||
fail: number;
|
||||
}
|
||||
|
||||
// API can return multiple entries for the same provider type, so we sum their findings
|
||||
function aggregateProvidersByType(
|
||||
providers: ProviderOverview[],
|
||||
): AggregatedProvider[] {
|
||||
const aggregated = new Map<string, AggregatedProvider>();
|
||||
|
||||
for (const provider of providers) {
|
||||
const { id, attributes } = provider;
|
||||
|
||||
const existing = aggregated.get(id);
|
||||
|
||||
if (existing) {
|
||||
existing.pass += attributes.findings.pass;
|
||||
existing.fail += attributes.findings.fail;
|
||||
} else {
|
||||
aggregated.set(id, {
|
||||
id,
|
||||
displayName: getProviderDisplayName(id),
|
||||
pass: attributes.findings.pass,
|
||||
fail: attributes.findings.fail,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(aggregated.values());
|
||||
export interface SeverityByProviderType {
|
||||
[providerType: string]: SeverityData;
|
||||
}
|
||||
|
||||
const SEVERITY_ORDER = [
|
||||
@@ -75,76 +41,79 @@ const SEVERITY_ORDER = [
|
||||
"Informational",
|
||||
] as const;
|
||||
|
||||
const SEVERITY_KEYS: (keyof SeverityData)[] = [
|
||||
"critical",
|
||||
"high",
|
||||
"medium",
|
||||
"low",
|
||||
"informational",
|
||||
];
|
||||
|
||||
/**
|
||||
* Adapts providers overview and findings severity API responses to Sankey chart format.
|
||||
* Severity distribution is calculated proportionally based on each provider's fail count.
|
||||
* Adapts severity by provider type data to Sankey chart format.
|
||||
*
|
||||
* @param providersResponse - The providers overview API response
|
||||
* @param severityResponse - The findings severity API response
|
||||
* @param filters - Optional filters to restrict which providers are shown.
|
||||
* When filters are set, only selected providers are shown.
|
||||
* When no filters, all providers are shown.
|
||||
* @param severityByProviderType - Severity breakdown per provider type from the API
|
||||
* @param selectedProviderTypes - Provider types that were selected but may have no data
|
||||
*/
|
||||
export function adaptProvidersOverviewToSankey(
|
||||
providersResponse: ProvidersOverviewResponse | undefined,
|
||||
severityResponse?: FindingsSeverityOverviewResponse | undefined,
|
||||
filters?: SankeyFilters,
|
||||
export function adaptToSankeyData(
|
||||
severityByProviderType: SeverityByProviderType,
|
||||
selectedProviderTypes?: string[],
|
||||
): SankeyData {
|
||||
if (!providersResponse?.data || providersResponse.data.length === 0) {
|
||||
return { nodes: [], links: [], zeroDataProviders: [] };
|
||||
if (Object.keys(severityByProviderType).length === 0) {
|
||||
// No data - check if there are selected providers to show as zero-data
|
||||
const zeroDataProviders: ZeroDataProvider[] = (
|
||||
selectedProviderTypes || []
|
||||
).map((type) => ({
|
||||
id: type.toLowerCase(),
|
||||
displayName: getProviderDisplayName(type),
|
||||
}));
|
||||
return { nodes: [], links: [], zeroDataProviders };
|
||||
}
|
||||
|
||||
const aggregatedProviders = aggregateProvidersByType(providersResponse.data);
|
||||
// Calculate total fails per provider to identify which have data
|
||||
const providersWithData: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
totalFail: number;
|
||||
}[] = [];
|
||||
const providersWithoutData: ZeroDataProvider[] = [];
|
||||
|
||||
// Filter providers based on selection:
|
||||
// - If providerTypes filter is set: show only those provider types
|
||||
// - Otherwise: show all providers from the API response
|
||||
const hasProviderTypeFilter =
|
||||
filters?.providerTypes && filters.providerTypes.length > 0;
|
||||
for (const [providerType, severity] of Object.entries(
|
||||
severityByProviderType,
|
||||
)) {
|
||||
const totalFail =
|
||||
severity.critical +
|
||||
severity.high +
|
||||
severity.medium +
|
||||
severity.low +
|
||||
severity.informational;
|
||||
|
||||
let providersToShow: AggregatedProvider[];
|
||||
if (hasProviderTypeFilter) {
|
||||
// Show only selected provider types
|
||||
providersToShow = aggregatedProviders.filter((p) =>
|
||||
filters.providerTypes!.includes(p.id.toLowerCase()),
|
||||
);
|
||||
} else {
|
||||
// No provider type filter - show all providers from the API response
|
||||
// Providers with no findings (pass=0, fail=0) will appear in the legend
|
||||
providersToShow = aggregatedProviders;
|
||||
const normalizedType = providerType.toLowerCase();
|
||||
|
||||
if (totalFail > 0) {
|
||||
providersWithData.push({
|
||||
id: normalizedType,
|
||||
displayName: getProviderDisplayName(normalizedType),
|
||||
totalFail,
|
||||
});
|
||||
} else {
|
||||
providersWithoutData.push({
|
||||
id: normalizedType,
|
||||
displayName: getProviderDisplayName(normalizedType),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (providersToShow.length === 0) {
|
||||
return { nodes: [], links: [], zeroDataProviders: [] };
|
||||
}
|
||||
|
||||
// Separate providers with and without failures
|
||||
const providersWithFailures = providersToShow.filter((p) => p.fail > 0);
|
||||
const providersWithoutFailures = providersToShow.filter((p) => p.fail === 0);
|
||||
|
||||
// Zero-data providers to show as legends below the chart
|
||||
const zeroDataProviders: ZeroDataProvider[] = providersWithoutFailures.map(
|
||||
(p) => ({
|
||||
id: p.id,
|
||||
displayName: p.displayName,
|
||||
}),
|
||||
);
|
||||
|
||||
// Add selected provider types that are completely missing from API response
|
||||
// (these are providers with zero findings - not even in the response)
|
||||
if (
|
||||
filters?.allSelectedProviderTypes &&
|
||||
filters.allSelectedProviderTypes.length > 0
|
||||
) {
|
||||
// Add selected provider types that are not in the response at all
|
||||
if (selectedProviderTypes && selectedProviderTypes.length > 0) {
|
||||
const existingProviderIds = new Set(
|
||||
aggregatedProviders.map((p) => p.id.toLowerCase()),
|
||||
Object.keys(severityByProviderType).map((t) => t.toLowerCase()),
|
||||
);
|
||||
|
||||
for (const selectedType of filters.allSelectedProviderTypes) {
|
||||
for (const selectedType of selectedProviderTypes) {
|
||||
const normalizedType = selectedType.toLowerCase();
|
||||
if (!existingProviderIds.has(normalizedType)) {
|
||||
// This provider type was selected but has no data at all
|
||||
zeroDataProviders.push({
|
||||
providersWithoutData.push({
|
||||
id: normalizedType,
|
||||
displayName: getProviderDisplayName(normalizedType),
|
||||
});
|
||||
@@ -152,65 +121,42 @@ export function adaptProvidersOverviewToSankey(
|
||||
}
|
||||
}
|
||||
|
||||
// If no providers have failures, return empty chart with legends
|
||||
if (providersWithFailures.length === 0) {
|
||||
return { nodes: [], links: [], zeroDataProviders };
|
||||
// If no providers have failures, return empty chart with zero-data legends
|
||||
if (providersWithData.length === 0) {
|
||||
return { nodes: [], links: [], zeroDataProviders: providersWithoutData };
|
||||
}
|
||||
|
||||
// Only include providers WITH failures in the chart
|
||||
const providerNodes: SankeyNode[] = providersWithFailures.map((p) => ({
|
||||
// Build nodes: providers first, then severities
|
||||
const providerNodes: SankeyNode[] = providersWithData.map((p) => ({
|
||||
name: p.displayName,
|
||||
}));
|
||||
const severityNodes: SankeyNode[] = SEVERITY_ORDER.map((severity) => ({
|
||||
name: severity,
|
||||
}));
|
||||
const nodes = [...providerNodes, ...severityNodes];
|
||||
|
||||
// Build links
|
||||
const severityStartIndex = providerNodes.length;
|
||||
const links: SankeyLink[] = [];
|
||||
|
||||
if (severityResponse?.data?.attributes) {
|
||||
const { critical, high, medium, low, informational } =
|
||||
severityResponse.data.attributes;
|
||||
providersWithData.forEach((provider, sourceIndex) => {
|
||||
const severity =
|
||||
severityByProviderType[provider.id] ||
|
||||
severityByProviderType[provider.id.toUpperCase()];
|
||||
|
||||
const severityValues = [critical, high, medium, low, informational];
|
||||
const totalSeverity = severityValues.reduce((sum, v) => sum + v, 0);
|
||||
|
||||
if (totalSeverity > 0) {
|
||||
const totalFails = providersWithFailures.reduce(
|
||||
(sum, p) => sum + p.fail,
|
||||
0,
|
||||
);
|
||||
|
||||
providersWithFailures.forEach((provider, sourceIndex) => {
|
||||
const providerRatio = provider.fail / totalFails;
|
||||
|
||||
severityValues.forEach((severityValue, severityIndex) => {
|
||||
const value = Math.round(severityValue * providerRatio);
|
||||
|
||||
if (value > 0) {
|
||||
links.push({
|
||||
source: sourceIndex,
|
||||
target: severityStartIndex + severityIndex,
|
||||
value,
|
||||
});
|
||||
}
|
||||
});
|
||||
if (severity) {
|
||||
SEVERITY_KEYS.forEach((key, severityIndex) => {
|
||||
const value = severity[key];
|
||||
if (value > 0) {
|
||||
links.push({
|
||||
source: sourceIndex,
|
||||
target: severityStartIndex + severityIndex,
|
||||
value,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Fallback when no severity data available
|
||||
const failNode: SankeyNode = { name: "Fail" };
|
||||
nodes.push(failNode);
|
||||
const failIndex = nodes.length - 1;
|
||||
});
|
||||
|
||||
providersWithFailures.forEach((provider, sourceIndex) => {
|
||||
links.push({
|
||||
source: sourceIndex,
|
||||
target: failIndex,
|
||||
value: provider.fail,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { nodes, links, zeroDataProviders };
|
||||
return { nodes, links, zeroDataProviders: providersWithoutData };
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface ThreatMapLocation {
|
||||
providerType: string;
|
||||
coordinates: [number, number];
|
||||
totalFindings: number;
|
||||
failFindings: number;
|
||||
riskLevel: "low-high" | "high" | "critical";
|
||||
severityData: Array<{
|
||||
name: string;
|
||||
@@ -147,6 +148,71 @@ const GCP_REGION_COORDINATES: Record<string, { lat: number; lng: number }> = {
|
||||
"me-central2": { lat: 24.5, lng: 54.4 }, // Dammam
|
||||
"me-west1": { lat: 32.1, lng: 34.8 }, // Tel Aviv
|
||||
"africa-south1": { lat: -26.2, lng: 28.0 }, // Johannesburg
|
||||
// Add global as default fallback for any unrecognized GCP region
|
||||
global: { lat: 37.4, lng: -122.1 }, // Mountain View, CA (Google HQ)
|
||||
};
|
||||
|
||||
// Kubernetes can run anywhere, using global fallback for any region
|
||||
const KUBERNETES_COORDINATES: Record<string, { lat: number; lng: number }> = {
|
||||
global: { lat: 37.7, lng: -122.4 }, // Global fallback
|
||||
};
|
||||
|
||||
// M365 regions (Microsoft datacenter locations)
|
||||
const M365_COORDINATES: Record<string, { lat: number; lng: number }> = {
|
||||
global: { lat: 47.6, lng: -122.3 }, // Global fallback
|
||||
};
|
||||
|
||||
// GitHub regions
|
||||
const GITHUB_COORDINATES: Record<string, { lat: number; lng: number }> = {
|
||||
global: { lat: 37.8, lng: -122.4 }, // Global fallback
|
||||
};
|
||||
|
||||
// IAC has no regions - it's code scanning
|
||||
const IAC_COORDINATES: Record<string, { lat: number; lng: number }> = {
|
||||
global: { lat: 40.7, lng: -74.0 }, // Global fallback
|
||||
};
|
||||
|
||||
// Oracle Cloud Infrastructure regions
|
||||
const ORACLECLOUD_COORDINATES: Record<string, { lat: number; lng: number }> = {
|
||||
// Americas
|
||||
"us-phoenix-1": { lat: 33.4, lng: -112.1 },
|
||||
"us-ashburn-1": { lat: 39.0, lng: -77.5 },
|
||||
"us-sanjose-1": { lat: 37.3, lng: -121.9 },
|
||||
"ca-toronto-1": { lat: 43.7, lng: -79.4 },
|
||||
"ca-montreal-1": { lat: 45.5, lng: -73.6 },
|
||||
"sa-saopaulo-1": { lat: -23.5, lng: -46.6 },
|
||||
"sa-santiago-1": { lat: -33.4, lng: -70.6 },
|
||||
// Europe
|
||||
"uk-london-1": { lat: 51.5, lng: -0.1 },
|
||||
"eu-frankfurt-1": { lat: 50.1, lng: 8.7 },
|
||||
"eu-zurich-1": { lat: 47.4, lng: 8.5 },
|
||||
"eu-amsterdam-1": { lat: 52.4, lng: 4.9 },
|
||||
"eu-paris-1": { lat: 48.9, lng: 2.3 },
|
||||
"eu-marseille-1": { lat: 43.3, lng: 5.4 },
|
||||
"eu-stockholm-1": { lat: 59.3, lng: 18.1 },
|
||||
"eu-milan-1": { lat: 45.5, lng: 9.2 },
|
||||
// Middle East & Africa
|
||||
"me-jeddah-1": { lat: 21.5, lng: 39.2 },
|
||||
"me-dubai-1": { lat: 25.3, lng: 55.3 },
|
||||
"il-jerusalem-1": { lat: 31.8, lng: 35.2 },
|
||||
"af-johannesburg-1": { lat: -26.2, lng: 28.0 },
|
||||
// Asia Pacific
|
||||
"ap-mumbai-1": { lat: 19.1, lng: 72.9 },
|
||||
"ap-tokyo-1": { lat: 35.7, lng: 139.7 },
|
||||
"ap-osaka-1": { lat: 34.7, lng: 135.5 },
|
||||
"ap-seoul-1": { lat: 37.6, lng: 127.0 },
|
||||
"ap-sydney-1": { lat: -33.9, lng: 151.2 },
|
||||
"ap-melbourne-1": { lat: -37.8, lng: 145.0 },
|
||||
"ap-singapore-1": { lat: 1.3, lng: 103.8 },
|
||||
"ap-hyderabad-1": { lat: 17.4, lng: 78.5 },
|
||||
"ap-chuncheon-1": { lat: 37.9, lng: 127.7 },
|
||||
global: { lat: 37.5, lng: -122.3 }, // Global fallback
|
||||
};
|
||||
|
||||
// MongoDB Atlas runs on AWS/Azure/GCP infrastructure
|
||||
// Using global fallback since it inherits regions from underlying cloud provider
|
||||
const MONGODBATLAS_COORDINATES: Record<string, { lat: number; lng: number }> = {
|
||||
global: { lat: 40.8, lng: -74.0 }, // Global fallback
|
||||
};
|
||||
|
||||
const PROVIDER_COORDINATES: Record<
|
||||
@@ -156,6 +222,14 @@ const PROVIDER_COORDINATES: Record<
|
||||
aws: AWS_REGION_COORDINATES,
|
||||
azure: AZURE_REGION_COORDINATES,
|
||||
gcp: GCP_REGION_COORDINATES,
|
||||
google: GCP_REGION_COORDINATES, // Alias for gcp
|
||||
"google-cloud": GCP_REGION_COORDINATES, // Alternative naming
|
||||
kubernetes: KUBERNETES_COORDINATES,
|
||||
m365: M365_COORDINATES,
|
||||
github: GITHUB_COORDINATES,
|
||||
iac: IAC_COORDINATES,
|
||||
oraclecloud: ORACLECLOUD_COORDINATES,
|
||||
mongodbatlas: MONGODBATLAS_COORDINATES,
|
||||
};
|
||||
|
||||
// Returns [lng, lat] format for D3/GeoJSON compatibility
|
||||
@@ -163,8 +237,19 @@ function getRegionCoordinates(
|
||||
providerType: string,
|
||||
region: string,
|
||||
): [number, number] | null {
|
||||
const coords =
|
||||
PROVIDER_COORDINATES[providerType.toLowerCase()]?.[region.toLowerCase()];
|
||||
const provider = providerType.toLowerCase();
|
||||
const providerCoords = PROVIDER_COORDINATES[provider];
|
||||
|
||||
if (!providerCoords) return null;
|
||||
|
||||
// Try to find specific region coordinates
|
||||
let coords = providerCoords[region.toLowerCase()];
|
||||
|
||||
// For providers without traditional regions, fallback to "global"
|
||||
if (!coords && providerCoords["global"]) {
|
||||
coords = providerCoords["global"];
|
||||
}
|
||||
|
||||
return coords ? [coords.lng, coords.lat] : null;
|
||||
}
|
||||
|
||||
@@ -240,8 +325,8 @@ export function adaptRegionsOverviewToThreatMap(
|
||||
|
||||
if (!coordinates) continue;
|
||||
|
||||
const providerRegion = getProviderDisplayName(attributes.provider_type);
|
||||
regionSet.add(providerRegion);
|
||||
// Add the actual region code to the set
|
||||
regionSet.add(attributes.region);
|
||||
|
||||
const failRate =
|
||||
attributes.total > 0 ? attributes.fail / attributes.total : 0;
|
||||
@@ -249,11 +334,12 @@ export function adaptRegionsOverviewToThreatMap(
|
||||
locations.push({
|
||||
id,
|
||||
name: formatRegionName(attributes.provider_type, attributes.region),
|
||||
region: providerRegion,
|
||||
region: attributes.region, // Use actual region code for filtering
|
||||
regionCode: attributes.region,
|
||||
providerType: attributes.provider_type,
|
||||
coordinates,
|
||||
totalFindings: attributes.fail,
|
||||
totalFindings: attributes.total,
|
||||
failFindings: attributes.fail,
|
||||
riskLevel: getRiskLevel(failRate),
|
||||
severityData: buildSeverityData(attributes.fail, attributes.pass),
|
||||
});
|
||||
|
||||
+60
-3
@@ -1,16 +1,63 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { AttackSurfaceItem } from "@/actions/overview";
|
||||
import { Card, CardContent } from "@/components/shadcn";
|
||||
import { mapProviderFiltersForFindings } from "@/lib";
|
||||
|
||||
interface AttackSurfaceCardItemProps {
|
||||
item: AttackSurfaceItem;
|
||||
filters?: Record<string, string | string[] | undefined>;
|
||||
}
|
||||
|
||||
export function AttackSurfaceCardItem({ item }: AttackSurfaceCardItemProps) {
|
||||
return (
|
||||
export function AttackSurfaceCardItem({
|
||||
item,
|
||||
filters = {},
|
||||
}: AttackSurfaceCardItemProps) {
|
||||
const hasCheckIds = item.checkIds.length > 0;
|
||||
|
||||
// Build URL with current filters + attack surface specific filters
|
||||
const buildFindingsUrl = () => {
|
||||
if (!hasCheckIds) return null;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Add attack surface specific filters
|
||||
params.set("filter[check_id__in]", item.checkIds.join(","));
|
||||
params.set("filter[status__in]", "FAIL");
|
||||
params.set("filter[muted]", "false");
|
||||
|
||||
// Add current page filters (provider, account, etc.)
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value !== undefined && !params.has(key)) {
|
||||
params.set(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
// Map provider filters for findings page compatibility
|
||||
mapProviderFiltersForFindings(params);
|
||||
|
||||
return `/findings?${params.toString()}`;
|
||||
};
|
||||
|
||||
const findingsUrl = buildFindingsUrl();
|
||||
|
||||
const hasFindings = item.failedFindings > 0;
|
||||
|
||||
const getCardStyles = () => {
|
||||
if (!hasCheckIds) {
|
||||
return "opacity-50 cursor-not-allowed";
|
||||
}
|
||||
if (hasFindings) {
|
||||
return "cursor-pointer border-rose-500/40 shadow-[0_0_12px_rgba(244,63,94,0.2)] transition-all hover:border-rose-500/60 hover:shadow-[0_0_16px_rgba(244,63,94,0.3)]";
|
||||
}
|
||||
return "cursor-pointer transition-colors hover:bg-accent";
|
||||
};
|
||||
|
||||
const cardContent = (
|
||||
<Card
|
||||
variant="inner"
|
||||
padding="md"
|
||||
className="flex min-h-[120px] min-w-[200px] flex-1 flex-col justify-between"
|
||||
className={`flex min-h-[120px] min-w-[200px] flex-1 flex-col justify-between ${getCardStyles()}`}
|
||||
aria-label={`${item.label}: ${item.failedFindings} failed findings`}
|
||||
>
|
||||
<CardContent className="flex flex-col gap-2 p-0">
|
||||
@@ -26,4 +73,14 @@ export function AttackSurfaceCardItem({ item }: AttackSurfaceCardItemProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
if (findingsUrl) {
|
||||
return (
|
||||
<Link href={findingsUrl} className="flex min-w-[200px] flex-1">
|
||||
{cardContent}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return cardContent;
|
||||
}
|
||||
|
||||
@@ -18,5 +18,5 @@ export const AttackSurfaceSSR = async ({
|
||||
|
||||
const items = adaptAttackSurfaceOverview(response);
|
||||
|
||||
return <AttackSurface items={items} />;
|
||||
return <AttackSurface items={items} filters={filters} />;
|
||||
};
|
||||
|
||||
@@ -5,9 +5,10 @@ import { AttackSurfaceCardItem } from "./attack-surface-card-item";
|
||||
|
||||
interface AttackSurfaceProps {
|
||||
items: AttackSurfaceItem[];
|
||||
filters?: Record<string, string | string[] | undefined>;
|
||||
}
|
||||
|
||||
export function AttackSurface({ items }: AttackSurfaceProps) {
|
||||
export function AttackSurface({ items, filters }: AttackSurfaceProps) {
|
||||
const isEmpty = items.length === 0;
|
||||
|
||||
return (
|
||||
@@ -25,7 +26,11 @@ export function AttackSurface({ items }: AttackSurfaceProps) {
|
||||
</div>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<AttackSurfaceCardItem key={item.id} item={item} />
|
||||
<AttackSurfaceCardItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
filters={filters}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
+92
-39
@@ -1,8 +1,7 @@
|
||||
import {
|
||||
adaptProvidersOverviewToSankey,
|
||||
adaptToSankeyData,
|
||||
getFindingsBySeverity,
|
||||
getProvidersOverview,
|
||||
SankeyFilters,
|
||||
SeverityByProviderType,
|
||||
} from "@/actions/overview";
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { SankeyChart } from "@/components/graphs/sankey-chart";
|
||||
@@ -17,57 +16,111 @@ export async function RiskPipelineViewSSR({
|
||||
}) {
|
||||
const filters = pickFilterParams(searchParams);
|
||||
|
||||
// Check if any provider/account filter is active
|
||||
const providerTypeFilter = filters["filter[provider_type__in]"];
|
||||
const providerIdFilter = filters["filter[provider_id__in]"];
|
||||
|
||||
// Fetch data in parallel
|
||||
const [providersResponse, severityResponse, providersListResponse] =
|
||||
await Promise.all([
|
||||
getProvidersOverview({ filters }),
|
||||
getFindingsBySeverity({ filters }),
|
||||
// Only fetch providers list if we need to look up account IDs
|
||||
providerIdFilter && !providerTypeFilter
|
||||
? getProviders({ pageSize: 200 })
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
// Fetch providers list to know account types
|
||||
const providersListResponse = await getProviders({ pageSize: 200 });
|
||||
const allProviders = providersListResponse?.data || [];
|
||||
|
||||
// Determine provider types to show
|
||||
let providerTypesToShow: string[] | undefined;
|
||||
// Build severityByProviderType based on filters
|
||||
const severityByProviderType: SeverityByProviderType = {};
|
||||
let selectedProviderTypes: string[] | undefined;
|
||||
|
||||
if (providerTypeFilter) {
|
||||
// Provider type filter is set - use it directly
|
||||
providerTypesToShow = String(providerTypeFilter)
|
||||
.split(",")
|
||||
.map((t) => t.trim().toLowerCase());
|
||||
} else if (providerIdFilter && providersListResponse?.data) {
|
||||
// Account filter is set - look up provider types from account IDs
|
||||
if (providerIdFilter) {
|
||||
// Case: Accounts are selected - group by provider type and make parallel calls
|
||||
const selectedAccountIds = String(providerIdFilter)
|
||||
.split(",")
|
||||
.map((id) => id.trim());
|
||||
|
||||
const providerTypesSet = new Set<string>();
|
||||
// Group selected accounts by provider type
|
||||
const accountsByType: Record<string, string[]> = {};
|
||||
for (const accountId of selectedAccountIds) {
|
||||
const provider = providersListResponse.data.find(
|
||||
(p) => p.id === accountId,
|
||||
);
|
||||
const provider = allProviders.find((p) => p.id === accountId);
|
||||
if (provider) {
|
||||
providerTypesSet.add(provider.attributes.provider.toLowerCase());
|
||||
const type = provider.attributes.provider.toLowerCase();
|
||||
if (!accountsByType[type]) {
|
||||
accountsByType[type] = [];
|
||||
}
|
||||
accountsByType[type].push(accountId);
|
||||
}
|
||||
}
|
||||
|
||||
selectedProviderTypes = Object.keys(accountsByType);
|
||||
|
||||
// Make parallel calls for each provider type
|
||||
const severityPromises = Object.entries(accountsByType).map(
|
||||
async ([providerType, accountIds]) => {
|
||||
const response = await getFindingsBySeverity({
|
||||
filters: {
|
||||
"filter[provider_id__in]": accountIds.join(","),
|
||||
"filter[status]": "FAIL", // Only count failed findings
|
||||
},
|
||||
});
|
||||
return { providerType, data: response?.data?.attributes };
|
||||
},
|
||||
);
|
||||
|
||||
const severityResults = await Promise.all(severityPromises);
|
||||
|
||||
for (const result of severityResults) {
|
||||
if (result.data) {
|
||||
severityByProviderType[result.providerType] = result.data;
|
||||
}
|
||||
}
|
||||
} else if (providerTypeFilter) {
|
||||
// Case: Provider types are selected - make parallel calls for each type
|
||||
selectedProviderTypes = String(providerTypeFilter)
|
||||
.split(",")
|
||||
.map((t) => t.trim().toLowerCase());
|
||||
|
||||
const severityPromises = selectedProviderTypes.map(async (providerType) => {
|
||||
const response = await getFindingsBySeverity({
|
||||
filters: {
|
||||
...filters,
|
||||
"filter[provider_type__in]": providerType,
|
||||
"filter[status]": "FAIL", // Only count failed findings
|
||||
},
|
||||
});
|
||||
return { providerType, data: response?.data?.attributes };
|
||||
});
|
||||
|
||||
const severityResults = await Promise.all(severityPromises);
|
||||
|
||||
for (const result of severityResults) {
|
||||
if (result.data) {
|
||||
severityByProviderType[result.providerType] = result.data;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Case: No filters - get all provider types and make parallel calls
|
||||
const allProviderTypes = Array.from(
|
||||
new Set(allProviders.map((p) => p.attributes.provider.toLowerCase())),
|
||||
);
|
||||
|
||||
const severityPromises = allProviderTypes.map(async (providerType) => {
|
||||
const response = await getFindingsBySeverity({
|
||||
filters: {
|
||||
...filters,
|
||||
"filter[provider_type__in]": providerType,
|
||||
"filter[status]": "FAIL", // Only count failed findings
|
||||
},
|
||||
});
|
||||
return { providerType, data: response?.data?.attributes };
|
||||
});
|
||||
|
||||
const severityResults = await Promise.all(severityPromises);
|
||||
|
||||
for (const result of severityResults) {
|
||||
if (result.data) {
|
||||
severityByProviderType[result.providerType] = result.data;
|
||||
}
|
||||
}
|
||||
providerTypesToShow = Array.from(providerTypesSet);
|
||||
}
|
||||
|
||||
// Build sankey filters
|
||||
const sankeyFilters: SankeyFilters = {
|
||||
providerTypes: providerTypesToShow,
|
||||
allSelectedProviderTypes: providerTypesToShow,
|
||||
};
|
||||
|
||||
const sankeyData = adaptProvidersOverviewToSankey(
|
||||
providersResponse,
|
||||
severityResponse,
|
||||
sankeyFilters,
|
||||
const sankeyData = adaptToSankeyData(
|
||||
severityByProviderType,
|
||||
selectedProviderTypes,
|
||||
);
|
||||
|
||||
// If no chart data and no zero-data providers, show empty state message
|
||||
|
||||
@@ -23,7 +23,6 @@ export const ServiceWatchlist = ({ items }: { items: ServiceOverview[] }) => {
|
||||
.slice(0, 5)
|
||||
.map((item) => ({
|
||||
key: item.id,
|
||||
icon: <div className="bg-bg-data-muted size-3 rounded-sm" />,
|
||||
label: item.id,
|
||||
value: item.attributes.fail,
|
||||
}));
|
||||
@@ -34,6 +33,7 @@ export const ServiceWatchlist = ({ items }: { items: ServiceOverview[] }) => {
|
||||
mapProviderFiltersForFindings(params);
|
||||
|
||||
params.set("filter[service__in]", item.key);
|
||||
params.set("filter[status__in]", "FAIL");
|
||||
router.push(`/findings?${params.toString()}`);
|
||||
};
|
||||
|
||||
@@ -53,6 +53,7 @@ export const ServiceWatchlist = ({ items }: { items: ServiceOverview[] }) => {
|
||||
message: "This space is looking empty.",
|
||||
}}
|
||||
onItemClick={handleItemClick}
|
||||
useFailureColoring
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -40,8 +40,14 @@ const getScoreTextColor = (score: number): string => {
|
||||
return SCORE_CONFIG.WARNING.textColor;
|
||||
};
|
||||
|
||||
const getFailureTextColor = (value: number): string => {
|
||||
return value === 0
|
||||
? SCORE_CONFIG.PASS.textColor
|
||||
: SCORE_CONFIG.FAIL.textColor;
|
||||
};
|
||||
|
||||
export interface WatchlistItem {
|
||||
icon: ReactNode;
|
||||
icon?: ReactNode;
|
||||
label: string;
|
||||
key: string;
|
||||
value: string | number;
|
||||
@@ -60,6 +66,11 @@ export interface WatchlistCardProps
|
||||
linkText?: string;
|
||||
};
|
||||
onItemClick?: (item: WatchlistItem) => void;
|
||||
/**
|
||||
* When true, uses failure-based coloring: green for 0, red otherwise.
|
||||
* When false (default), uses score-based coloring (0-30 red, 31-60 yellow, 61-100 green).
|
||||
*/
|
||||
useFailureColoring?: boolean;
|
||||
}
|
||||
|
||||
export const WatchlistCard = ({
|
||||
@@ -70,6 +81,7 @@ export const WatchlistCard = ({
|
||||
headerAction,
|
||||
emptyState,
|
||||
onItemClick,
|
||||
useFailureColoring = false,
|
||||
}: WatchlistCardProps) => {
|
||||
const isEmpty = items.length === 0;
|
||||
|
||||
@@ -116,9 +128,11 @@ export const WatchlistCard = ({
|
||||
? parseFloat(item.value.replace("%", ""))
|
||||
: item.value;
|
||||
|
||||
// Get color based on score
|
||||
// Get color based on score or failure count
|
||||
const valueColorClass = !isNaN(numericValue)
|
||||
? getScoreTextColor(numericValue)
|
||||
? useFailureColoring
|
||||
? getFailureTextColor(numericValue)
|
||||
: getScoreTextColor(numericValue)
|
||||
: "text-text-neutral-tertiary";
|
||||
|
||||
const isClickable = !!onItemClick;
|
||||
@@ -142,9 +156,11 @@ export const WatchlistCard = ({
|
||||
"hover:bg-bg-neutral-tertiary cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<div className="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-md bg-white">
|
||||
{item.icon}
|
||||
</div>
|
||||
{item.icon && (
|
||||
<div className="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-md bg-white">
|
||||
{item.icon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-text-neutral-secondary flex-1 truncate text-sm leading-6">
|
||||
{item.label}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -57,7 +57,6 @@ export const ComplianceHeader = ({
|
||||
labelCheckboxGroup: "Regions",
|
||||
values: uniqueRegions,
|
||||
index: 1, // Show after framework filters
|
||||
defaultToSelectAll: true, // Default to all regions selected
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -265,7 +265,7 @@ export const FindingDetail = ({
|
||||
)}
|
||||
|
||||
<InfoField label="Categories">
|
||||
{attributes.check_metadata.categories?.join(", ") || "-"}
|
||||
{attributes.check_metadata.categories?.join(", ") || "none"}
|
||||
</InfoField>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
+278
-354
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -4,4 +4,5 @@ export * from "./helper";
|
||||
export * from "./helper-filters";
|
||||
export * from "./menu-list";
|
||||
export * from "./permissions";
|
||||
export * from "./provider-helpers";
|
||||
export * from "./utils";
|
||||
|
||||
Reference in New Issue
Block a user