Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7d9a1ac
fix: remove icons from service watchlist
Alan-TheGentleman Nov 28, 2025
6536763
feat: add threat map support for all provider types
Alan-TheGentleman Nov 28, 2025
a83f4ac
fix: add global region support for GCP in threat map
Alan-TheGentleman Nov 28, 2025
9a76798
fix: improve threat map region filtering and global region handling
Alan-TheGentleman Nov 28, 2025
5171b88
fix: resolve infinite loop on hover in threat map
Alan-TheGentleman Nov 28, 2025
3d841f6
refactor: extract threat map types and utils for better maintainability
Alan-TheGentleman Nov 28, 2025
d730f81
feat: reset bar chart on region change and show global aggregated data
Alan-TheGentleman Nov 28, 2025
0b6f063
fix: update rows per page styling and regions select all behavior
Alan-TheGentleman Nov 28, 2025
a332956
fix(ui): improve select behavior, status labels, and navigation filters
Alan-TheGentleman Dec 1, 2025
339450b
fix(ui): improve pagination width, chart tooltips, sankey empty state…
Alan-TheGentleman Dec 2, 2025
e98e64b
refactor(ui): simplify sankey adapter with new providers/severity end…
Alan-TheGentleman Dec 2, 2025
178e262
fix(ui): correct sankey chart severity counts and navigation filters
Alan-TheGentleman Dec 3, 2025
0709d3d
fix(ui): filter failed findings and sort by severity in resource detail
Alan-TheGentleman Dec 3, 2025
eb188b8
fix(ui): correct total findings calculation in threat map
Alan-TheGentleman Dec 3, 2025
108f11c
fix(ui): remove default select all from compliance region filter
Alan-TheGentleman Dec 3, 2025
126c998
fix(ui): use correct CSS classes for password policy indicators
Alan-TheGentleman Dec 3, 2025
eeb01f7
Merge branch 'master' into fix/ui-bugs-collection
Alan-TheGentleman Dec 3, 2025
9ad59dc
fix: threat map should include exclude findings in the url when click…
Alan-TheGentleman Dec 3, 2025
368eeef
feat(ui): make attack surface cards clickable with findings navigation
Alan-TheGentleman Dec 3, 2025
7985a9a
feat(ui): add failure-based coloring for service watchlist
Alan-TheGentleman Dec 3, 2025
9691dd1
style(ui): fix prettier formatting issues
Alan-TheGentleman Dec 3, 2025
8ea5940
feat(ui): add FAIL status filter on service watchlist click
Alan-TheGentleman Dec 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ui/actions/overview/attack-surface.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface AttackSurfaceItem {
label: string;
failedFindings: number;
totalFindings: number;
checkIds: string[];
}

const ATTACK_SURFACE_LABELS: Record<AttackSurfaceId, string> = {
Expand All @@ -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 ?? [],
};
}

Expand Down
238 changes: 92 additions & 146 deletions ui/actions/overview/sankey.adapter.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
import { getProviderDisplayName } from "@/types/providers";

import {
FindingsSeverityOverviewResponse,
ProviderOverview,
ProvidersOverviewResponse,
} from "./types";

export interface SankeyNode {
name: string;
}
Expand All @@ -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 = [
Expand All @@ -75,142 +41,122 @@ 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: [] };
}

const aggregatedProviders = aggregateProvidersByType(providersResponse.data);

// 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;

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;
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 };
}

if (providersToShow.length === 0) {
return { nodes: [], links: [], zeroDataProviders: [] };
// Calculate total fails per provider to identify which have data
const providersWithData: {
id: string;
displayName: string;
totalFail: number;
}[] = [];
const providersWithoutData: ZeroDataProvider[] = [];

for (const [providerType, severity] of Object.entries(
severityByProviderType,
)) {
const totalFail =
severity.critical +
severity.high +
severity.medium +
severity.low +
severity.informational;

const normalizedType = providerType.toLowerCase();

if (totalFail > 0) {
providersWithData.push({
id: normalizedType,
displayName: getProviderDisplayName(normalizedType),
totalFail,
});
} else {
providersWithoutData.push({
id: normalizedType,
displayName: getProviderDisplayName(normalizedType),
});
}
}

// 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),
});
}
}
}

// 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;

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,
});
}
});
providersWithData.forEach((provider, sourceIndex) => {
const severity =
severityByProviderType[provider.id] ||
severityByProviderType[provider.id.toUpperCase()];

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 };
}
Loading
Loading