feat(ui): add Finding Severity Over Time chart to overview page (#9405)

This commit is contained in:
Alan Buscaglia
2025-12-04 13:19:15 +01:00
committed by GitHub
parent 9c387d5742
commit 7f12832808
100 changed files with 1267 additions and 948 deletions

View File

@@ -6,6 +6,7 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🚀 Added
- Finding Severity Over Time chart component to Overview page [(#9405)](https://github.com/prowler-cloud/prowler/pull/9405)
- Attack Surface component to Overview page [(#9412)](https://github.com/prowler-cloud/prowler/pull/9412)
- Compliance Watchlist component to Overview page [(#9199)](https://github.com/prowler-cloud/prowler/pull/9199)
- Service Watchlist component to Overview page [(#9316)](https://github.com/prowler-cloud/prowler/pull/9316)

View File

@@ -0,0 +1,2 @@
export * from "./api-keys";
export * from "./api-keys.adapter";

View File

@@ -1,38 +1,12 @@
import { StaticImageData } from "next/image";
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
import { MetaDataProps } from "@/types";
import { ComplianceOverviewData } from "@/types/compliance";
/**
* Raw API response from /compliance-overviews endpoint
*/
export interface ComplianceOverviewsResponse {
data: ComplianceOverviewData[];
meta?: {
pagination?: {
page: number;
pages: number;
count: number;
};
};
}
import {
ComplianceOverviewsResponse,
EnrichedComplianceOverview,
} from "./types";
/**
* Enriched compliance overview with computed fields
*/
export interface EnrichedComplianceOverview {
id: string;
framework: string;
version: string;
requirements_passed: number;
requirements_failed: number;
requirements_manual: number;
total_requirements: number;
score: number;
label: string;
icon: string | StaticImageData | undefined;
}
export type { ComplianceOverviewsResponse, EnrichedComplianceOverview };
/**
* Formats framework name for display by replacing hyphens with spaces

View File

@@ -1,2 +1,6 @@
export * from "./compliances";
export * from "./compliances.adapter";
export type {
ComplianceOverviewsResponse,
EnrichedComplianceOverview,
} from "./types";

View File

@@ -0,0 +1,33 @@
import { StaticImageData } from "next/image";
import { ComplianceOverviewData } from "@/types/compliance";
/**
* Raw API response from /compliance-overviews endpoint
*/
export interface ComplianceOverviewsResponse {
data: ComplianceOverviewData[];
meta?: {
pagination?: {
page: number;
pages: number;
count: number;
};
};
}
/**
* Enriched compliance overview with computed fields
*/
export interface EnrichedComplianceOverview {
id: string;
framework: string;
version: string;
requirements_passed: number;
requirements_failed: number;
requirements_manual: number;
total_requirements: number;
score: number;
label: string;
icon: string | StaticImageData | undefined;
}

View File

@@ -1,7 +1,4 @@
import {
AttackSurfaceOverview,
AttackSurfaceOverviewResponse,
} from "./types/attack-surface";
import { AttackSurfaceOverview, AttackSurfaceOverviewResponse } from "../types";
const ATTACK_SURFACE_IDS = {
INTERNET_EXPOSED: "internet-exposed",

View File

@@ -0,0 +1,34 @@
"use server";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiResponse } from "@/lib/server-actions-helper";
import { AttackSurfaceOverviewResponse } from "../types";
export const getAttackSurfaceOverview = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<AttackSurfaceOverviewResponse | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/overviews/attack-surfaces`);
// Handle multiple filters
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]" && value !== undefined) {
url.searchParams.append(key, String(value));
}
});
try {
const response = await fetch(url.toString(), {
headers,
});
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching attack surface overview:", error);
return undefined;
}
};

View File

@@ -0,0 +1,2 @@
export * from "./attack-surface";
export * from "./attack-surface.adapter";

View File

@@ -0,0 +1,86 @@
"use server";
import { redirect } from "next/navigation";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiResponse } from "@/lib/server-actions-helper";
import { FindingsSeverityOverviewResponse } from "../types";
export const getFindingsByStatus = async ({
page = 1,
query = "",
sort = "",
filters = {},
}: {
page?: number;
query?: string;
sort?: string;
filters?: Record<string, string | string[] | undefined>;
} = {}) => {
const headers = await getAuthHeaders({ contentType: false });
if (isNaN(Number(page)) || page < 1) redirect("/");
const url = new URL(`${apiBaseUrl}/overviews/findings`);
if (page) url.searchParams.append("page[number]", page.toString());
if (query) url.searchParams.append("filter[search]", query);
if (sort) url.searchParams.append("sort", sort);
// Handle multiple filters, but exclude muted filter as overviews endpoint doesn't support it
Object.entries(filters).forEach(([key, value]) => {
// The overviews/findings endpoint does not support status or muted filters
// (allowed filters include date, region, provider fields). Exclude unsupported ones.
if (
key !== "filter[search]" &&
key !== "filter[muted]" &&
key !== "filter[status]"
) {
url.searchParams.append(key, String(value));
}
});
try {
const response = await fetch(url.toString(), {
headers,
});
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching findings severity overview:", error);
return undefined;
}
};
export const getFindingsBySeverity = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<FindingsSeverityOverviewResponse | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/overviews/findings_severity`);
// Handle multiple filters, but exclude unsupported filters
Object.entries(filters).forEach(([key, value]) => {
if (
key !== "filter[search]" &&
key !== "filter[muted]" &&
value !== undefined
) {
url.searchParams.append(key, String(value));
}
});
try {
const response = await fetch(url.toString(), {
headers,
});
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching findings severity overview:", error);
return undefined;
}
};

View File

@@ -0,0 +1 @@
export * from "./findings";

View File

@@ -1,5 +1,9 @@
export * from "./attack-surface.adapter";
export * from "./overview";
export * from "./sankey.adapter";
export * from "./threat-map.adapter";
// Re-export all overview actions from feature-based subfolders
export * from "./attack-surface";
export * from "./findings";
export * from "./providers";
export * from "./regions";
export * from "./services";
export * from "./severity-trends";
export * from "./threat-score";
export * from "./types";

View File

@@ -1,243 +0,0 @@
"use server";
import { redirect } from "next/navigation";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiResponse } from "@/lib/server-actions-helper";
import {
AttackSurfaceOverviewResponse,
FindingsSeverityOverviewResponse,
ProvidersOverviewResponse,
RegionsOverviewResponse,
ServicesOverviewResponse,
} from "./types";
export const getServicesOverview = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<ServicesOverviewResponse | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/overviews/services`);
// Handle multiple filters
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]" && value !== undefined) {
url.searchParams.append(key, String(value));
}
});
try {
const response = await fetch(url.toString(), {
headers,
});
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching services overview:", error);
return undefined;
}
};
export const getProvidersOverview = async ({
page = 1,
query = "",
sort = "",
filters = {},
}: {
page?: number;
query?: string;
sort?: string;
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<ProvidersOverviewResponse | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
if (isNaN(Number(page)) || page < 1) redirect("/providers-overview");
const url = new URL(`${apiBaseUrl}/overviews/providers`);
if (page) url.searchParams.append("page[number]", page.toString());
if (query) url.searchParams.append("filter[search]", query);
if (sort) url.searchParams.append("sort", sort);
// Handle multiple filters
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]" && value !== undefined) {
url.searchParams.append(key, String(value));
}
});
try {
const response = await fetch(url.toString(), {
headers,
});
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching providers overview:", error);
return undefined;
}
};
export const getFindingsByStatus = async ({
page = 1,
query = "",
sort = "",
filters = {},
}: {
page?: number;
query?: string;
sort?: string;
filters?: Record<string, string | string[] | undefined>;
} = {}) => {
const headers = await getAuthHeaders({ contentType: false });
if (isNaN(Number(page)) || page < 1) redirect("/");
const url = new URL(`${apiBaseUrl}/overviews/findings`);
if (page) url.searchParams.append("page[number]", page.toString());
if (query) url.searchParams.append("filter[search]", query);
if (sort) url.searchParams.append("sort", sort);
// Handle multiple filters, but exclude muted filter as overviews endpoint doesn't support it
Object.entries(filters).forEach(([key, value]) => {
// The overviews/findings endpoint does not support status or muted filters
// (allowed filters include date, region, provider fields). Exclude unsupported ones.
if (
key !== "filter[search]" &&
key !== "filter[muted]" &&
key !== "filter[status]"
) {
url.searchParams.append(key, String(value));
}
});
try {
const response = await fetch(url.toString(), {
headers,
});
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching findings severity overview:", error);
return undefined;
}
};
export const getFindingsBySeverity = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<FindingsSeverityOverviewResponse | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/overviews/findings_severity`);
// Handle multiple filters, but exclude unsupported filters
Object.entries(filters).forEach(([key, value]) => {
if (
key !== "filter[search]" &&
key !== "filter[muted]" &&
value !== undefined
) {
url.searchParams.append(key, String(value));
}
});
try {
const response = await fetch(url.toString(), {
headers,
});
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching findings severity overview:", error);
return undefined;
}
};
export const getThreatScore = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/overviews/threatscore`);
// Handle multiple filters
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]") {
url.searchParams.append(key, String(value));
}
});
try {
const response = await fetch(url.toString(), {
headers,
});
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching threat score:", error);
return undefined;
}
};
export const getRegionsOverview = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<RegionsOverviewResponse | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/overviews/regions`);
// Handle multiple filters
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]" && value !== undefined) {
url.searchParams.append(key, String(value));
}
});
try {
const response = await fetch(url.toString(), {
headers,
});
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching regions overview:", error);
return undefined;
}
};
export const getAttackSurfaceOverview = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<AttackSurfaceOverviewResponse | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/overviews/attack-surfaces`);
// Handle multiple filters
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]" && value !== undefined) {
url.searchParams.append(key, String(value));
}
});
try {
const response = await fetch(url.toString(), {
headers,
});
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching attack surface overview:", error);
return undefined;
}
};

View File

@@ -0,0 +1,2 @@
export * from "./providers";
export * from "./sankey.adapter";

View File

@@ -0,0 +1,47 @@
"use server";
import { redirect } from "next/navigation";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiResponse } from "@/lib/server-actions-helper";
import { ProvidersOverviewResponse } from "../types";
export const getProvidersOverview = async ({
page = 1,
query = "",
sort = "",
filters = {},
}: {
page?: number;
query?: string;
sort?: string;
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<ProvidersOverviewResponse | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
if (isNaN(Number(page)) || page < 1) redirect("/providers-overview");
const url = new URL(`${apiBaseUrl}/overviews/providers`);
if (page) url.searchParams.append("page[number]", page.toString());
if (query) url.searchParams.append("filter[search]", query);
if (sort) url.searchParams.append("sort", sort);
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]" && value !== undefined) {
url.searchParams.append(key, String(value));
}
});
try {
const response = await fetch(url.toString(), {
headers,
});
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching providers overview:", error);
return undefined;
}
};

View File

@@ -0,0 +1,2 @@
export * from "./regions";
export * from "./threat-map.adapter";

View File

@@ -0,0 +1,34 @@
"use server";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiResponse } from "@/lib/server-actions-helper";
import { RegionsOverviewResponse } from "../types";
export const getRegionsOverview = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<RegionsOverviewResponse | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/overviews/regions`);
// Handle multiple filters
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]" && value !== undefined) {
url.searchParams.append(key, String(value));
}
});
try {
const response = await fetch(url.toString(), {
headers,
});
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching regions overview:", error);
return undefined;
}
};

View File

@@ -1,6 +1,6 @@
import { getProviderDisplayName } from "@/types/providers";
import { RegionsOverviewResponse } from "./types";
import { RegionsOverviewResponse } from "../types";
export interface ThreatMapLocation {
id: string;

View File

@@ -0,0 +1 @@
export * from "./services";

View File

@@ -0,0 +1,33 @@
"use server";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiResponse } from "@/lib/server-actions-helper";
import { ServicesOverviewResponse } from "../types";
export const getServicesOverview = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<ServicesOverviewResponse | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/overviews/services`);
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]" && value !== undefined) {
url.searchParams.append(key, String(value));
}
});
try {
const response = await fetch(url.toString(), {
headers,
});
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching services overview:", error);
return undefined;
}
};

View File

@@ -1,186 +0,0 @@
"use server";
const TIME_RANGE_OPTIONS = {
ONE_DAY: { value: "1D", days: 1 },
FIVE_DAYS: { value: "5D", days: 5 },
ONE_WEEK: { value: "1W", days: 7 },
ONE_MONTH: { value: "1M", days: 30 },
} as const;
type TimeRange =
(typeof TIME_RANGE_OPTIONS)[keyof typeof TIME_RANGE_OPTIONS]["value"];
const getFindingsSeverityTrends = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}) => {
// TODO: Replace with actual API call when endpoint is available
// const headers = await getAuthHeaders({ contentType: false });
// const url = new URL(`${apiBaseUrl}/findings/severity/time-series`);
// Object.entries(filters).forEach(([key, value]) => {
// if (value) url.searchParams.append(key, String(value));
// });
// const response = await fetch(url.toString(), { headers });
// return handleApiResponse(response);
// Extract date range from filters to simulate different data based on selection
const startDateStr = filters["filter[inserted_at__gte]"] as
| string
| undefined;
const endDateStr = filters["filter[inserted_at__lte]"] as string | undefined;
// Generate mock data based on the date range
let mockData;
if (startDateStr && endDateStr) {
const startDate = new Date(startDateStr);
const endDate = new Date(endDateStr);
const daysDiff = Math.ceil(
(endDate.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000),
);
// Generate data points for each day in the range
const dataPoints = [];
for (let i = 0; i <= daysDiff; i++) {
const currentDate = new Date(startDate);
currentDate.setDate(currentDate.getDate() + i);
const dateStr = currentDate.toISOString().split("T")[0];
// Vary the data based on the day for visual difference
const dayOffset = i;
dataPoints.push({
type: "severity-time-series",
id: dateStr,
date: `${dateStr}T00:00:00Z`,
informational: Math.max(0, 380 + dayOffset * 15),
low: Math.max(0, 720 + dayOffset * 20),
medium: Math.max(0, 550 + dayOffset * 10),
high: Math.max(0, 1000 - dayOffset * 5),
critical: Math.max(0, 1200 - dayOffset * 30),
muted: Math.max(0, 500 - dayOffset * 25),
});
}
mockData = {
data: dataPoints,
links: {
self: `https://api.prowler.com/api/v1/findings/severity/time-series?start=${startDateStr}&end=${endDateStr}`,
},
meta: {
date_range: `${startDateStr} to ${endDateStr}`,
days: daysDiff,
granularity: "daily",
timezone: "UTC",
},
};
} else {
// Default 5-day data if no date range provided
mockData = {
data: [
{
type: "severity-time-series",
id: "2025-10-26",
date: "2025-10-26T00:00:00Z",
informational: 420,
low: 950,
medium: 720,
high: 1150,
critical: 1350,
muted: 600,
},
{
type: "severity-time-series",
id: "2025-10-27",
date: "2025-10-27T00:00:00Z",
informational: 450,
low: 1100,
medium: 850,
high: 1300,
critical: 1500,
muted: 700,
},
{
type: "severity-time-series",
id: "2025-10-28",
date: "2025-10-28T00:00:00Z",
informational: 400,
low: 850,
medium: 650,
high: 1200,
critical: 2000,
muted: 750,
},
{
type: "severity-time-series",
id: "2025-10-29",
date: "2025-10-29T00:00:00Z",
informational: 380,
low: 720,
medium: 550,
high: 1000,
critical: 1200,
muted: 500,
},
{
type: "severity-time-series",
id: "2025-11-10",
date: "2025-11-10T00:00:00Z",
informational: 500,
low: 750,
medium: 350,
high: 1000,
critical: 550,
muted: 100,
},
],
links: {
self: "https://api.prowler.com/api/v1/findings/severity/time-series?range=5D",
},
meta: {
time_range: "5D",
granularity: "daily",
timezone: "UTC",
},
};
}
return mockData;
};
export const getSeverityTrendsByTimeRange = async ({
timeRange,
filters = {},
}: {
timeRange: TimeRange;
filters?: Record<string, string | string[] | undefined>;
}) => {
// Find the days value from TIME_RANGE_OPTIONS
const timeRangeConfig = Object.values(TIME_RANGE_OPTIONS).find(
(option) => option.value === timeRange,
);
if (!timeRangeConfig) {
throw new Error(`Invalid time range: ${timeRange}`);
}
const endDate = new Date();
const startDate = new Date(
endDate.getTime() - timeRangeConfig.days * 24 * 60 * 60 * 1000,
);
// Format dates as ISO strings for API
const startDateStr = startDate.toISOString().split("T")[0];
const endDateStr = endDate.toISOString().split("T")[0];
// Add date filters to the request
const dateFilters = {
...filters,
"filter[inserted_at__gte]": startDateStr,
"filter[inserted_at__lte]": endDateStr,
};
return getFindingsSeverityTrends({ filters: dateFilters });
};
export { getFindingsSeverityTrends };

View File

@@ -0,0 +1,2 @@
export * from "./severity-trends";
export * from "./severity-trends.adapter";

View File

@@ -0,0 +1,48 @@
import { LineDataPoint } from "@/components/graphs/types";
import {
AdaptedSeverityTrendsResponse,
FindingsSeverityOverTimeResponse,
} from "../types";
export type { AdaptedSeverityTrendsResponse, FindingsSeverityOverTimeResponse };
/**
* Adapts the API findings severity over time response to the format expected by LineChart.
* Transforms API response with nested attributes into flat LineDataPoint objects.
*
* @param response - The raw API response from /overviews/findings_severity_over_time
* @returns Adapted response with LineDataPoint array ready for the chart
*/
export function adaptSeverityTrendsResponse(
response: FindingsSeverityOverTimeResponse,
): AdaptedSeverityTrendsResponse {
const adaptedData: LineDataPoint[] = response.data.map(
({
id,
attributes: {
informational,
low,
medium,
high,
critical,
muted,
scan_ids,
},
}) => ({
date: id,
informational,
low,
medium,
high,
critical,
muted,
scan_ids,
}),
);
return {
data: adaptedData,
meta: response.meta,
};
}

View File

@@ -0,0 +1,99 @@
"use server";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiResponse } from "@/lib/server-actions-helper";
import {
AdaptedSeverityTrendsResponse,
FindingsSeverityOverTimeResponse,
} from "../types";
import { adaptSeverityTrendsResponse } from "./severity-trends.adapter";
const TIME_RANGE_VALUES = {
FIVE_DAYS: "5D",
ONE_WEEK: "1W",
ONE_MONTH: "1M",
} as const;
type TimeRange = (typeof TIME_RANGE_VALUES)[keyof typeof TIME_RANGE_VALUES];
const TIME_RANGE_DAYS: Record<TimeRange, number> = {
"5D": 5,
"1W": 7,
"1M": 30,
};
export type SeverityTrendsResult =
| { status: "success"; data: AdaptedSeverityTrendsResponse }
| { status: "empty" }
| { status: "error" };
const getFindingsSeverityTrends = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<SeverityTrendsResult> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/overviews/findings_severity_over_time`);
Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined) {
url.searchParams.append(key, String(value));
}
});
try {
const response = await fetch(url.toString(), {
headers,
});
const apiResponse: FindingsSeverityOverTimeResponse | undefined =
await handleApiResponse(response);
if (!apiResponse?.data || !Array.isArray(apiResponse.data)) {
return { status: "empty" };
}
if (apiResponse.data.length === 0) {
return { status: "empty" };
}
return {
status: "success",
data: adaptSeverityTrendsResponse(apiResponse),
};
} catch (error) {
console.error("Error fetching findings severity trends:", error);
return { status: "error" };
}
};
export const getSeverityTrendsByTimeRange = async ({
timeRange,
filters = {},
}: {
timeRange: TimeRange;
filters?: Record<string, string | string[] | undefined>;
}): Promise<SeverityTrendsResult> => {
const days = TIME_RANGE_DAYS[timeRange];
if (!days) {
console.error("Invalid time range provided");
return { status: "error" };
}
const endDate = new Date();
const startDate = new Date(endDate.getTime() - days * 24 * 60 * 60 * 1000);
const dateFrom = startDate.toISOString().split("T")[0];
const dateFilters = {
...filters,
date_from: dateFrom,
};
return getFindingsSeverityTrends({ filters: dateFilters });
};
export { getFindingsSeverityTrends };

View File

@@ -0,0 +1 @@
export * from "./threat-score";

View File

@@ -0,0 +1,32 @@
"use server";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiResponse } from "@/lib/server-actions-helper";
export const getThreatScore = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/overviews/threatscore`);
// Handle multiple filters
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]") {
url.searchParams.append(key, String(value));
}
});
try {
const response = await fetch(url.toString(), {
headers,
});
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching threat score:", error);
return undefined;
}
};

View File

@@ -4,4 +4,5 @@ export * from "./findings-severity";
export * from "./providers";
export * from "./regions";
export * from "./services";
export * from "./severity-trends";
export * from "./threat-score";

View File

@@ -0,0 +1,33 @@
import { LineDataPoint } from "@/components/graphs/types";
// API Response Types (what comes from the backend)
export interface FindingsSeverityOverTimeAttributes {
critical: number;
high: number;
medium: number;
low: number;
informational: number;
muted: number;
scan_ids: string[];
}
export interface FindingsSeverityOverTimeItem {
type: "findings-severity-over-time";
id: string;
attributes: FindingsSeverityOverTimeAttributes;
}
export interface FindingsSeverityOverTimeMeta {
version: string;
}
export interface FindingsSeverityOverTimeResponse {
data: FindingsSeverityOverTimeItem[];
meta: FindingsSeverityOverTimeMeta;
}
// Adapted Types (what the UI components expect)
export interface AdaptedSeverityTrendsResponse {
data: LineDataPoint[];
meta: FindingsSeverityOverTimeMeta;
}

View File

@@ -1 +1,2 @@
export * from "./poll";
export * from "./tasks";

View File

@@ -0,0 +1,2 @@
export * from "./tenants";
export * from "./users";

View File

@@ -0,0 +1,9 @@
import { SearchParamsProps } from "@/types";
/**
* Common props interface for SSR components that receive search params
* from the page component for filter handling.
*/
export interface SSRComponentProps {
searchParams: SearchParamsProps | undefined | null;
}

View File

@@ -1,4 +1,4 @@
import { AttackSurfaceItem } from "@/actions/overview/attack-surface.adapter";
import { AttackSurfaceItem } from "@/actions/overview";
import { Card, CardContent, CardTitle } from "@/components/shadcn";
import { AttackSurfaceCardItem } from "./attack-surface-card-item";

View File

@@ -2,16 +2,12 @@ import {
adaptAttackSurfaceOverview,
getAttackSurfaceOverview,
} from "@/actions/overview";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../lib/filter-params";
import { AttackSurface } from "./attack-surface";
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
import { AttackSurface } from "./_components/attack-surface";
export const AttackSurfaceSSR = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
export const AttackSurfaceSSR = async ({ searchParams }: SSRComponentProps) => {
const filters = pickFilterParams(searchParams);
const response = await getAttackSurfaceOverview({ filters });

View File

@@ -1,4 +1,2 @@
export { AttackSurface } from "./attack-surface";
export { AttackSurfaceSSR } from "./attack-surface.ssr";
export { AttackSurfaceCardItem } from "./attack-surface-card-item";
export { AttackSurfaceSkeleton } from "./attack-surface-skeleton";

View File

@@ -1,14 +1,10 @@
import { getFindingsByStatus } from "@/actions/overview/overview";
import { SearchParamsProps } from "@/types";
import { getFindingsByStatus } from "@/actions/overview";
import { pickFilterParams } from "../../lib/filter-params";
import { StatusChart } from "../status-chart/status-chart";
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
import { StatusChart } from "../status-chart/_components/status-chart";
export const CheckFindingsSSR = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
export const CheckFindingsSSR = async ({ searchParams }: SSRComponentProps) => {
const filters = pickFilterParams(searchParams);
const findingsByStatus = await getFindingsByStatus({ filters });

View File

@@ -1,38 +0,0 @@
import { getFindingsSeverityTrends } from "@/actions/overview/severity-trends";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../lib/filter-params";
import { FindingSeverityOverTime } from "./finding-severity-over-time";
export const FindingSeverityOverTimeDetailSSR = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
const filters = pickFilterParams(searchParams);
const severityTrends = await getFindingsSeverityTrends({ filters });
if (
!severityTrends ||
!severityTrends.data ||
severityTrends.data.length === 0
) {
return (
<div className="border-border-neutral-primary bg-bg-neutral-secondary flex h-[400px] w-full items-center justify-center rounded-xl border">
<p className="text-text-neutral-tertiary">
Failed to load severity trends data
</p>
</div>
);
}
return (
<div className="border-border-neutral-primary bg-bg-neutral-secondary overflow-visible rounded-lg border p-4">
<h3 className="text-text-neutral-primary mb-4 text-lg font-semibold">
Finding Severity Over Time
</h3>
<FindingSeverityOverTime data={severityTrends.data} />
</div>
);
};

View File

@@ -1,49 +0,0 @@
import { getFindingsSeverityTrends } from "@/actions/overview/severity-trends";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../lib/filter-params";
import {
FindingSeverityOverTime,
FindingSeverityOverTimeSkeleton,
} from "./finding-severity-over-time";
export { FindingSeverityOverTimeSkeleton };
export const FindingSeverityOverTimeSSR = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
const filters = pickFilterParams(searchParams);
const severityTrends = await getFindingsSeverityTrends({ filters });
if (
!severityTrends ||
!severityTrends.data ||
severityTrends.data.length === 0
) {
return (
<div className="border-border-neutral-primary bg-bg-neutral-secondary flex h-[400px] w-full items-center justify-center rounded-xl border">
<p className="text-text-neutral-tertiary">
Failed to load severity trends data
</p>
</div>
);
}
return (
<Card variant="base" className="flex h-full flex-1 flex-col">
<CardHeader className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<CardTitle>Finding Severity Over Time</CardTitle>
</div>
</CardHeader>
<CardContent className="flex flex-1 flex-col px-6">
<FindingSeverityOverTime data={severityTrends.data} />
</CardContent>
</Card>
);
};

View File

@@ -1,115 +0,0 @@
"use client";
import { useState } from "react";
import { getSeverityTrendsByTimeRange } from "@/actions/overview/severity-trends";
import { LineChart } from "@/components/graphs/line-chart";
import { LineConfig, LineDataPoint } from "@/components/graphs/types";
import { Skeleton } from "@/components/shadcn";
import { SEVERITY_LINE_CONFIGS } from "@/types/severities";
import { type TimeRange, TimeRangeSelector } from "./time-range-selector";
interface SeverityDataPoint {
type: string;
id: string;
date: string;
informational: number;
low: number;
medium: number;
high: number;
critical: number;
muted?: number;
}
interface FindingSeverityOverTimeProps {
data: SeverityDataPoint[];
}
export const FindingSeverityOverTime = ({
data: initialData,
}: FindingSeverityOverTimeProps) => {
const [timeRange, setTimeRange] = useState<TimeRange>("5D");
const [data, setData] = useState<SeverityDataPoint[]>(initialData);
const [isLoading, setIsLoading] = useState(false);
const handleTimeRangeChange = async (newRange: TimeRange) => {
setTimeRange(newRange);
setIsLoading(true);
try {
const response = await getSeverityTrendsByTimeRange({
timeRange: newRange,
});
if (response?.data) {
setData(response.data);
}
} catch (error) {
console.error("Error fetching severity trends");
} finally {
setIsLoading(false);
}
};
// Transform API data into LineDataPoint format
const chartData: LineDataPoint[] = data.map((item) => {
const date = new Date(item.date);
const formattedDate = date.toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
});
return {
date: formattedDate,
informational: item.informational,
low: item.low,
medium: item.medium,
high: item.high,
critical: item.critical,
...(item.muted && { muted: item.muted }),
};
});
// Build line configurations from shared severity configs
const lines: LineConfig[] = [...SEVERITY_LINE_CONFIGS];
// Only add muted line if data contains it (CSS var for Recharts inline styles)
if (data.some((item) => item.muted !== undefined)) {
lines.push({
dataKey: "muted",
color: "var(--color-bg-data-muted)",
label: "Muted",
});
}
return (
<>
<div className="mb-8 w-fit">
<TimeRangeSelector
value={timeRange}
onChange={handleTimeRangeChange}
isLoading={isLoading}
/>
</div>
<div className="mb-4 w-full">
<LineChart data={chartData} lines={lines} height={400} />
</div>
</>
);
};
export function FindingSeverityOverTimeSkeleton() {
return (
<>
<div className="mb-8 w-fit">
<div className="flex gap-2">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-10 w-12 rounded-full" />
))}
</div>
</div>
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
);
}

View File

@@ -1,3 +0,0 @@
export { FindingSeverityOverTime } from "./finding-severity-over-time";
export { FindingSeverityOverTimeSSR } from "./finding-severity-over-time.ssr";
export { TimeRangeSelector } from "./time-range-selector";

View File

@@ -1,5 +0,0 @@
export {
RiskSeverityChart,
RiskSeverityChartSkeleton,
} from "./risk-severity-chart";
export { RiskSeverityChartSSR } from "./risk-severity-chart.ssr";

View File

@@ -1 +0,0 @@
export { StatusChart, StatusChartSkeleton } from "./status-chart";

View File

@@ -1,2 +0,0 @@
export { ThreatScore, ThreatScoreSkeleton } from "./threat-score";
export { ThreatScoreSSR } from "./threat-score.ssr";

View File

@@ -1,7 +0,0 @@
export type { ComplianceData } from "./compliance-watchlist";
export { ComplianceWatchlist } from "./compliance-watchlist";
export { ComplianceWatchlistSSR } from "./compliance-watchlist.ssr";
export { ServiceWatchlist } from "./service-watchlist";
export { ServiceWatchlistSSR } from "./service-watchlist.ssr";
export { SortToggleButton } from "./sort-toggle-button";
export * from "./watchlist-card";

View File

@@ -4,7 +4,7 @@ import { useRef, useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/shadcn";
import { GRAPH_TABS, type TabId } from "./graphs-tabs-config";
import { GRAPH_TABS, type TabId } from "../_config/graphs-tabs-config";
interface GraphsTabsClientProps {
tabsContent: Record<TabId, React.ReactNode>;

View File

@@ -3,24 +3,15 @@
import { Spacer } from "@heroui/spacer";
import { getLatestFindings } from "@/actions/findings/findings";
import { LighthouseBanner } from "@/components/lighthouse/banner";
import { LinkToFindings } from "@/components/overview";
import { ColumnNewFindingsToDate } from "@/components/overview/new-findings-table/table/column-new-findings-to-date";
import { DataTable } from "@/components/ui/table";
import { createDict } from "@/lib/helper";
import { mapProviderFiltersForFindingsObject } from "@/lib/provider-helpers";
import { FindingProps, SearchParamsProps } from "@/types";
import { LighthouseBanner } from "../../../../../../components/lighthouse/banner";
const FILTER_PREFIX = "filter[";
function pickFilterParams(
params: SearchParamsProps | undefined | null,
): Record<string, string | string[] | undefined> {
if (!params) return {};
return Object.fromEntries(
Object.entries(params).filter(([key]) => key.startsWith(FILTER_PREFIX)),
);
}
import { pickFilterParams } from "../../_lib/filter-params";
interface FindingsViewSSRProps {
searchParams: SearchParamsProps;
@@ -36,15 +27,7 @@ export async function FindingsViewSSR({ searchParams }: FindingsViewSSRProps) {
};
const filters = pickFilterParams(searchParams);
// Map provider_id__in to provider__in for findings API
const mappedFilters = { ...filters };
if (mappedFilters["filter[provider_id__in]"]) {
mappedFilters["filter[provider__in]"] =
mappedFilters["filter[provider_id__in]"];
delete mappedFilters["filter[provider_id__in]"];
}
const mappedFilters = mapProviderFiltersForFindingsObject(filters);
const combinedFilters = { ...defaultFilters, ...mappedFilters };
const findingsData = await getLatestFindings({

View File

@@ -3,9 +3,9 @@ import { Suspense } from "react";
import { SearchParamsProps } from "@/types";
import { GraphsTabsClient } from "./_components/graphs-tabs-client";
import { GRAPH_TABS, type TabId } from "./_config/graphs-tabs-config";
import { FindingsViewSSR } from "./findings-view";
import { GraphsTabsClient } from "./graphs-tabs-client";
import { GRAPH_TABS, type TabId } from "./graphs-tabs-config";
import { RiskPipelineViewSSR } from "./risk-pipeline-view/risk-pipeline-view.ssr";
import { ThreatMapViewSSR } from "./threat-map-view/threat-map-view.ssr";
// TODO: Uncomment when ready to enable other tabs

View File

@@ -7,7 +7,7 @@ import { getProviders } from "@/actions/providers";
import { SankeyChart } from "@/components/graphs/sankey-chart";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../../lib/filter-params";
import { pickFilterParams } from "../../_lib/filter-params";
export async function RiskPipelineViewSSR({
searchParams,

View File

@@ -5,7 +5,7 @@ import {
import { ThreatMap } from "@/components/graphs/threat-map";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../../lib/filter-params";
import { pickFilterParams } from "../../_lib/filter-params";
export async function ThreatMapViewSSR({
searchParams,

View File

@@ -4,23 +4,23 @@ import { getProviders } from "@/actions/providers";
import { ContentLayout } from "@/components/ui";
import { SearchParamsProps } from "@/types";
import { AccountsSelector } from "./components/accounts-selector";
import { CheckFindingsSSR } from "./components/check-findings";
import { AccountsSelector } from "./_components/accounts-selector";
import { ProviderTypeSelector } from "./_components/provider-type-selector";
import { CheckFindingsSSR } from "./check-findings";
import { GraphsTabsWrapper } from "./graphs-tabs/graphs-tabs-wrapper";
import { RiskSeverityChartSkeleton } from "./risk-severity";
import { RiskSeverityChartSSR } from "./risk-severity/risk-severity-chart.ssr";
import {
FindingSeverityOverTimeSkeleton,
FindingSeverityOverTimeSSR,
} from "./components/finding-severity-over-time/finding-severity-over-time.ssr";
import { GraphsTabsWrapper } from "./components/graphs-tabs/graphs-tabs-wrapper";
import { ProviderTypeSelector } from "./components/provider-type-selector";
import { RiskSeverityChartSkeleton } from "./components/risk-severity-chart";
import { RiskSeverityChartSSR } from "./components/risk-severity-chart/risk-severity-chart.ssr";
import { StatusChartSkeleton } from "./components/status-chart";
import { ThreatScoreSkeleton, ThreatScoreSSR } from "./components/threat-score";
} from "./severity-over-time/finding-severity-over-time.ssr";
import { StatusChartSkeleton } from "./status-chart";
import { ThreatScoreSkeleton, ThreatScoreSSR } from "./threat-score";
import {
ComplianceWatchlistSSR,
ServiceWatchlistSSR,
WatchlistCardSkeleton,
} from "./components/watchlist";
} from "./watchlist";
export default async function NewOverviewPage({
searchParams,

View File

@@ -0,0 +1,26 @@
import { Card, CardContent, CardHeader, Skeleton } from "@/components/shadcn";
export function RiskSeverityChartSkeleton() {
return (
<Card
variant="base"
className="flex min-h-[372px] min-w-[312px] flex-1 flex-col md:min-w-[380px]"
>
<CardHeader>
<Skeleton className="h-7 w-[260px] rounded-xl" />
</CardHeader>
<CardContent className="flex flex-1 items-center justify-start px-6">
<div className="flex w-full flex-col gap-6">
{/* 5 horizontal bar skeletons */}
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="flex h-7 w-full gap-6">
<Skeleton className="h-full w-28 shrink-0 rounded-xl" />
<Skeleton className="h-full flex-1 rounded-xl" />
</div>
))}
</div>
</CardContent>
</Card>
);
}

View File

@@ -4,13 +4,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart";
import { BarDataPoint } from "@/components/graphs/types";
import {
Card,
CardContent,
CardHeader,
CardTitle,
Skeleton,
} from "@/components/shadcn";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { mapProviderFiltersForFindings } from "@/lib/provider-helpers";
import { calculatePercentage } from "@/lib/utils";
import { SEVERITY_FILTER_MAP } from "@/types/severities";
@@ -100,28 +94,3 @@ export const RiskSeverityChart = ({
</Card>
);
};
export function RiskSeverityChartSkeleton() {
return (
<Card
variant="base"
className="flex min-h-[372px] min-w-[312px] flex-1 flex-col md:min-w-[380px]"
>
<CardHeader>
<Skeleton className="h-7 w-[260px] rounded-xl" />
</CardHeader>
<CardContent className="flex flex-1 items-center justify-start px-6">
<div className="flex w-full flex-col gap-6">
{/* 5 horizontal bar skeletons */}
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="flex h-7 w-full gap-6">
<Skeleton className="h-full w-28 shrink-0 rounded-xl" />
<Skeleton className="h-full flex-1 rounded-xl" />
</div>
))}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,3 @@
export { RiskSeverityChart } from "./_components/risk-severity-chart";
export { RiskSeverityChartSkeleton } from "./_components/risk-severity-chart.skeleton";
export { RiskSeverityChartSSR } from "./risk-severity-chart.ssr";

View File

@@ -1,14 +1,12 @@
import { getFindingsBySeverity } from "@/actions/overview/overview";
import { SearchParamsProps } from "@/types";
import { getFindingsBySeverity } from "@/actions/overview";
import { pickFilterParams } from "../../lib/filter-params";
import { RiskSeverityChart } from "./risk-severity-chart";
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
import { RiskSeverityChart } from "./_components/risk-severity-chart";
export const RiskSeverityChartDetailSSR = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
}: SSRComponentProps) => {
const filters = pickFilterParams(searchParams);
// Filter by FAIL findings
filters["filter[status]"] = "FAIL";

View File

@@ -1,13 +1,10 @@
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../lib/filter-params";
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
import { RiskSeverityChartDetailSSR } from "./risk-severity-chart-detail.ssr";
export const RiskSeverityChartSSR = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
}: SSRComponentProps) => {
const filters = pickFilterParams(searchParams);
return <RiskSeverityChartDetailSSR searchParams={filters} />;

View File

@@ -0,0 +1,16 @@
import { Skeleton } from "@/components/shadcn";
export function FindingSeverityOverTimeSkeleton() {
return (
<div role="status" aria-label="Loading severity trends">
<div className="mb-8 w-fit">
<div className="flex gap-2">
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} className="h-10 w-12 rounded-full" />
))}
</div>
</div>
<Skeleton className="h-[400px] w-full rounded-lg" />
</div>
);
}

View File

@@ -0,0 +1,144 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { getSeverityTrendsByTimeRange } from "@/actions/overview/severity-trends";
import { LineChart } from "@/components/graphs/line-chart";
import { LineConfig, LineDataPoint } from "@/components/graphs/types";
import {
MUTED_COLOR,
SEVERITY_LEVELS,
SEVERITY_LINE_CONFIGS,
SeverityLevel,
} from "@/types/severities";
import { type TimeRange, TimeRangeSelector } from "./time-range-selector";
interface FindingSeverityOverTimeProps {
data: LineDataPoint[];
}
export const FindingSeverityOverTime = ({
data: initialData,
}: FindingSeverityOverTimeProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const [timeRange, setTimeRange] = useState<TimeRange>("5D");
const [data, setData] = useState<LineDataPoint[]>(initialData);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handlePointClick = ({
point,
dataKey,
}: {
point: LineDataPoint;
dataKey?: string;
}) => {
const params = new URLSearchParams();
params.set("filter[inserted_at]", point.date);
// Add scan_ids filter
if (
point.scan_ids &&
Array.isArray(point.scan_ids) &&
point.scan_ids.length > 0
) {
params.set("filter[scan__in]", point.scan_ids.join(","));
}
// Add severity filter if clicked on a specific severity line
if (dataKey && SEVERITY_LEVELS.includes(dataKey as SeverityLevel)) {
params.set("filter[severity__in]", dataKey);
}
// Preserve provider filters from overview
const providerType = searchParams.get("filter[provider_type__in]");
const providerId = searchParams.get("filter[provider_id__in]");
if (providerType) {
params.set("filter[provider_type__in]", providerType);
}
if (providerId) {
params.set("filter[provider__in]", providerId);
}
router.push(`/findings?${params.toString()}`);
};
const handleTimeRangeChange = async (newRange: TimeRange) => {
setTimeRange(newRange);
setIsLoading(true);
setError(null);
try {
const result = await getSeverityTrendsByTimeRange({
timeRange: newRange,
});
if (result.status === "success") {
setData(result.data.data);
} else if (result.status === "empty") {
setData([]);
setError("No severity trends data available for this time range");
} else {
setError("Failed to load severity trends. Please try again.");
}
} catch (err) {
console.error("Error fetching severity trends:", err);
setError("Failed to load severity trends. Please try again.");
} finally {
setIsLoading(false);
}
};
// Build line configurations from shared severity configs
const lines: LineConfig[] = [...SEVERITY_LINE_CONFIGS];
// Only add muted line if data contains it
if (data.some((item) => item.muted !== undefined)) {
lines.push({
dataKey: "muted",
color: MUTED_COLOR,
label: "Muted",
});
}
// Calculate x-axis interval based on data length to show all labels without overlap
const getXAxisInterval = (): number => {
const dataLength = data.length;
if (dataLength <= 7) return 0; // Show all labels for 5D and 1W
return 0; // Show all labels for 1M too
};
return (
<>
<div className="mb-8 w-fit">
<TimeRangeSelector
value={timeRange}
onChange={handleTimeRangeChange}
isLoading={isLoading}
/>
</div>
{error ? (
<div
role="alert"
className="flex h-[400px] w-full items-center justify-center rounded-lg border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950"
>
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
) : (
<div className="mb-4 w-full">
<LineChart
data={data}
lines={lines}
height={400}
xAxisInterval={getXAxisInterval()}
onPointClick={handlePointClick}
/>
</div>
)}
</>
);
};

View File

@@ -3,7 +3,6 @@
import { cn } from "@/lib/utils";
const TIME_RANGE_OPTIONS = {
ONE_DAY: "1D",
FIVE_DAYS: "5D",
ONE_WEEK: "1W",
ONE_MONTH: "1M",

View File

@@ -0,0 +1,35 @@
import { getFindingsSeverityTrends } from "@/actions/overview/severity-trends";
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
import { FindingSeverityOverTime } from "./_components/finding-severity-over-time";
const EmptyState = ({ message }: { message: string }) => (
<div className="border-border-neutral-primary bg-bg-neutral-secondary flex h-[400px] w-full items-center justify-center rounded-xl border">
<p className="text-text-neutral-tertiary">{message}</p>
</div>
);
export const FindingSeverityOverTimeDetailSSR = async ({
searchParams,
}: SSRComponentProps) => {
const filters = pickFilterParams(searchParams);
const result = await getFindingsSeverityTrends({ filters });
if (result.status === "error") {
return <EmptyState message="Failed to load severity trends data" />;
}
if (result.status === "empty") {
return <EmptyState message="No severity trends data available" />;
}
return (
<div className="border-border-neutral-primary bg-bg-neutral-secondary overflow-visible rounded-lg border p-4">
<h3 className="text-text-neutral-primary mb-4 text-lg font-semibold">
Finding Severity Over Time
</h3>
<FindingSeverityOverTime data={result.data.data} />
</div>
);
};

View File

@@ -0,0 +1,51 @@
import { getFindingsSeverityTrends } from "@/actions/overview/severity-trends";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
import { FindingSeverityOverTime } from "./_components/finding-severity-over-time";
import { FindingSeverityOverTimeSkeleton } from "./_components/finding-severity-over-time.skeleton";
export { FindingSeverityOverTimeSkeleton };
const EmptyState = ({ message }: { message: string }) => (
<Card variant="base" className="flex h-full min-h-[405px] flex-1 flex-col">
<CardHeader className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<CardTitle>Finding Severity Over Time</CardTitle>
</div>
</CardHeader>
<CardContent className="flex flex-1 items-center justify-center">
<p className="text-text-neutral-tertiary">{message}</p>
</CardContent>
</Card>
);
export const FindingSeverityOverTimeSSR = async ({
searchParams,
}: SSRComponentProps) => {
const filters = pickFilterParams(searchParams);
const result = await getFindingsSeverityTrends({ filters });
if (result.status === "error") {
return <EmptyState message="Failed to load severity trends data" />;
}
if (result.status === "empty") {
return <EmptyState message="No severity trends data available" />;
}
return (
<Card variant="base" className="flex h-full flex-1 flex-col">
<CardHeader className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<CardTitle>Finding Severity Over Time</CardTitle>
</div>
</CardHeader>
<CardContent className="flex flex-1 flex-col px-6">
<FindingSeverityOverTime data={result.data.data} />
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,4 @@
export { FindingSeverityOverTime } from "./_components/finding-severity-over-time";
export { FindingSeverityOverTimeSkeleton } from "./_components/finding-severity-over-time.skeleton";
export { TimeRangeSelector } from "./_components/time-range-selector";
export { FindingSeverityOverTimeSSR } from "./finding-severity-over-time.ssr";

View File

@@ -0,0 +1,24 @@
import { Card, CardContent, CardHeader, Skeleton } from "@/components/shadcn";
export function StatusChartSkeleton() {
return (
<Card
variant="base"
className="flex min-h-[372px] min-w-[312px] flex-1 flex-col justify-between md:min-w-[380px]"
>
<CardHeader>
<Skeleton className="h-7 w-[260px] rounded-xl" />
</CardHeader>
<CardContent className="flex flex-1 flex-col justify-between space-y-4">
{/* Circular skeleton for donut chart */}
<div className="mx-auto h-[172px] w-[172px]">
<Skeleton className="size-[172px] rounded-full" />
</div>
{/* Bottom info box skeleton */}
<Skeleton className="h-[97px] w-full shrink-0 rounded-xl" />
</CardContent>
</Card>
);
}

View File

@@ -12,7 +12,6 @@ import {
CardTitle,
CardVariant,
ResourceStatsCard,
Skeleton,
} from "@/components/shadcn";
import { mapProviderFiltersForFindings } from "@/lib/provider-helpers";
import { calculatePercentage } from "@/lib/utils";
@@ -165,26 +164,3 @@ export const StatusChart = ({
</Card>
);
};
export function StatusChartSkeleton() {
return (
<Card
variant="base"
className="flex min-h-[372px] min-w-[312px] flex-1 flex-col justify-between md:min-w-[380px]"
>
<CardHeader>
<Skeleton className="h-7 w-[260px] rounded-xl" />
</CardHeader>
<CardContent className="flex flex-1 flex-col justify-between space-y-4">
{/* Circular skeleton for donut chart */}
<div className="mx-auto h-[172px] w-[172px]">
<Skeleton className="size-[172px] rounded-full" />
</div>
{/* Bottom info box skeleton */}
<Skeleton className="h-[97px] w-full shrink-0 rounded-xl" />
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,2 @@
export { StatusChart } from "./_components/status-chart";
export { StatusChartSkeleton } from "./_components/status-chart.skeleton";

View File

@@ -0,0 +1,24 @@
import { Card, CardContent, CardHeader, Skeleton } from "@/components/shadcn";
export function ThreatScoreSkeleton() {
return (
<Card
variant="base"
className="flex min-h-[372px] w-full flex-col justify-between lg:max-w-[312px]"
>
<CardHeader>
<Skeleton className="h-7 w-36 rounded-xl" />
</CardHeader>
<CardContent className="flex flex-1 flex-col justify-between space-y-4">
{/* Circular skeleton for radial chart */}
<div className="relative mx-auto h-[172px] w-full max-w-[250px]">
<Skeleton className="mx-auto size-[170px] rounded-full" />
</div>
{/* Bottom info box skeleton */}
<Skeleton className="h-[97px] w-full shrink-0 rounded-xl" />
</CardContent>
</Card>
);
}

View File

@@ -7,13 +7,7 @@ import type {
SectionScores,
} from "@/actions/overview/types";
import { RadialChart } from "@/components/graphs/radial-chart";
import {
Card,
CardContent,
CardHeader,
CardTitle,
Skeleton,
} from "@/components/shadcn";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
// CSS variables are required here as they're passed to RadialChart component
// which uses Recharts library that needs actual color values, not Tailwind classes
@@ -119,7 +113,7 @@ export function ThreatScore({
return (
<Card
variant="base"
className="flex min-h-[372px] min-w-[328px] flex-col justify-between md:max-w-[312px]"
className="flex min-h-[372px] w-full flex-col justify-between lg:max-w-[312px]"
>
<CardHeader>
<CardTitle>Prowler Threat Score</CardTitle>
@@ -208,26 +202,3 @@ export function ThreatScore({
</Card>
);
}
export function ThreatScoreSkeleton() {
return (
<Card
variant="base"
className="flex min-h-[372px] min-w-[328px] flex-col justify-between md:max-w-[312px]"
>
<CardHeader>
<Skeleton className="h-7 w-36 rounded-xl" />
</CardHeader>
<CardContent className="flex flex-1 flex-col justify-between space-y-4">
{/* Circular skeleton for radial chart */}
<div className="relative mx-auto h-[172px] w-full max-w-[250px]">
<Skeleton className="mx-auto size-[170px] rounded-full" />
</div>
{/* Bottom info box skeleton */}
<Skeleton className="h-[97px] w-full shrink-0 rounded-xl" />
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,3 @@
export { ThreatScore } from "./_components/threat-score";
export { ThreatScoreSkeleton } from "./_components/threat-score.skeleton";
export { ThreatScoreSSR } from "./threat-score.ssr";

View File

@@ -1,14 +1,10 @@
import { getThreatScore } from "@/actions/overview/overview";
import { SearchParamsProps } from "@/types";
import { getThreatScore } from "@/actions/overview";
import { pickFilterParams } from "../../lib/filter-params";
import { ThreatScore } from "./threat-score";
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
import { ThreatScore } from "./_components/threat-score";
export const ThreatScoreSSR = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
export const ThreatScoreSSR = async ({ searchParams }: SSRComponentProps) => {
const filters = pickFilterParams(searchParams);
const threatScoreData = await getThreatScore({ filters });

View File

@@ -9,7 +9,6 @@ import {
CardFooter,
CardTitle,
} from "@/components/shadcn/card/card";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { cn } from "@/lib/utils";
const SCORE_CONFIG = {
@@ -192,23 +191,3 @@ export const WatchlistCard = ({
</Card>
);
};
export function WatchlistCardSkeleton() {
return (
<Card variant="base" className="flex min-h-[500px] min-w-[312px] flex-col">
<CardTitle>
<Skeleton className="h-7 w-[168px] rounded-xl" />
</CardTitle>
<CardContent className="flex flex-1 flex-col justify-center gap-8">
{/* 6 skeleton rows */}
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="flex h-7 w-full items-start gap-6">
<Skeleton className="h-7 w-[168px] rounded-xl" />
<Skeleton className="h-7 flex-1 rounded-xl" />
</div>
))}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,22 @@
import { Card, CardContent, CardTitle } from "@/components/shadcn/card/card";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
export function WatchlistCardSkeleton() {
return (
<Card variant="base" className="flex min-h-[500px] min-w-[312px] flex-col">
<CardTitle>
<Skeleton className="h-7 w-[168px] rounded-xl" />
</CardTitle>
<CardContent className="flex flex-1 flex-col justify-center gap-8">
{/* 6 skeleton rows */}
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="flex h-7 w-full items-start gap-6">
<Skeleton className="h-7 w-[168px] rounded-xl" />
<Skeleton className="h-7 flex-1 rounded-xl" />
</div>
))}
</CardContent>
</Card>
);
}

View File

@@ -2,16 +2,14 @@ import {
adaptComplianceOverviewsResponse,
getCompliancesOverview,
} from "@/actions/compliances";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../lib/filter-params";
import { ComplianceWatchlist } from "./compliance-watchlist";
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
import { ComplianceWatchlist } from "./_components/compliance-watchlist";
export const ComplianceWatchlistSSR = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
}: SSRComponentProps) => {
const filters = pickFilterParams(searchParams);
const response = await getCompliancesOverview({ filters });

View File

@@ -0,0 +1,12 @@
export type { ComplianceData } from "./_components/compliance-watchlist";
export { ComplianceWatchlist } from "./_components/compliance-watchlist";
export { ServiceWatchlist } from "./_components/service-watchlist";
export { SortToggleButton } from "./_components/sort-toggle-button";
export { WatchlistCardSkeleton } from "./_components/watchlist.skeleton";
export {
WatchlistCard,
type WatchlistCardProps,
type WatchlistItem,
} from "./_components/watchlist-card";
export { ComplianceWatchlistSSR } from "./compliance-watchlist.ssr";
export { ServiceWatchlistSSR } from "./service-watchlist.ssr";

View File

@@ -1,14 +1,12 @@
import { getServicesOverview, ServiceOverview } from "@/actions/overview";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../lib/filter-params";
import { ServiceWatchlist } from "./service-watchlist";
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
import { ServiceWatchlist } from "./_components/service-watchlist";
export const ServiceWatchlistSSR = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
}: SSRComponentProps) => {
const filters = pickFilterParams(searchParams);
const response = await getServicesOverview({ filters });

View File

@@ -4,28 +4,32 @@ import { getProviders } from "@/actions/providers";
import { ContentLayout } from "@/components/ui";
import { SearchParamsProps } from "@/types";
import { AccountsSelector } from "./_new-overview/components/accounts-selector";
import { AccountsSelector } from "./_new-overview/_components/accounts-selector";
import { ProviderTypeSelector } from "./_new-overview/_components/provider-type-selector";
import {
AttackSurfaceSkeleton,
AttackSurfaceSSR,
} from "./_new-overview/components/attack-surface";
import { CheckFindingsSSR } from "./_new-overview/components/check-findings";
import { GraphsTabsWrapper } from "./_new-overview/components/graphs-tabs/graphs-tabs-wrapper";
import { RiskPipelineViewSkeleton } from "./_new-overview/components/graphs-tabs/risk-pipeline-view";
import { ProviderTypeSelector } from "./_new-overview/components/provider-type-selector";
} from "./_new-overview/attack-surface";
import { CheckFindingsSSR } from "./_new-overview/check-findings";
import { GraphsTabsWrapper } from "./_new-overview/graphs-tabs/graphs-tabs-wrapper";
import { RiskPipelineViewSkeleton } from "./_new-overview/graphs-tabs/risk-pipeline-view";
import {
RiskSeverityChartSkeleton,
RiskSeverityChartSSR,
} from "./_new-overview/components/risk-severity-chart";
import { StatusChartSkeleton } from "./_new-overview/components/status-chart";
} from "./_new-overview/risk-severity";
import {
FindingSeverityOverTimeSkeleton,
FindingSeverityOverTimeSSR,
} from "./_new-overview/severity-over-time/finding-severity-over-time.ssr";
import { StatusChartSkeleton } from "./_new-overview/status-chart";
import {
ThreatScoreSkeleton,
ThreatScoreSSR,
} from "./_new-overview/components/threat-score";
} from "./_new-overview/threat-score";
import {
ServiceWatchlistSSR,
WatchlistCardSkeleton,
} from "./_new-overview/components/watchlist";
} from "./_new-overview/watchlist";
export default async function Home({
searchParams,
@@ -42,7 +46,7 @@ export default async function Home({
<AccountsSelector providers={providersData?.data ?? []} />
</div>
<div className="flex flex-col gap-6 md:flex-row md:flex-wrap md:items-stretch">
<div className="flex flex-col gap-6 xl:flex-row xl:flex-wrap xl:items-stretch">
<Suspense fallback={<ThreatScoreSkeleton />}>
<ThreatScoreSSR searchParams={resolvedSearchParams} />
</Suspense>
@@ -62,10 +66,13 @@ export default async function Home({
</Suspense>
</div>
<div className="mt-6 flex flex-col gap-6 md:flex-row">
<div className="mt-6 flex flex-col gap-6 xl:flex-row">
<Suspense fallback={<WatchlistCardSkeleton />}>
<ServiceWatchlistSSR searchParams={resolvedSearchParams} />
</Suspense>
<Suspense fallback={<FindingSeverityOverTimeSkeleton />}>
<FindingSeverityOverTimeSSR searchParams={resolvedSearchParams} />
</Suspense>
</div>
<div className="mt-6">

View File

@@ -4,6 +4,7 @@ import { Button, ButtonGroup } from "@heroui/button";
import { DatePicker } from "@heroui/date-picker";
import {
getLocalTimeZone,
parseDate,
startOfMonth,
startOfWeek,
today,
@@ -20,7 +21,12 @@ export const CustomDatePicker = () => {
const [value, setValue] = React.useState(() => {
const dateParam = searchParams.get("filter[inserted_at]");
return dateParam ? today(getLocalTimeZone()) : null;
if (!dateParam) return null;
try {
return parseDate(dateParam);
} catch {
return null;
}
});
const { locale } = useLocale();

View File

@@ -19,16 +19,20 @@ import {
import { AlertPill } from "./shared/alert-pill";
import { ChartLegend } from "./shared/chart-legend";
import { CustomActiveDot, PointClickData } from "./shared/custom-active-dot";
import {
AXIS_FONT_SIZE,
CustomXAxisTickWithToday,
} from "./shared/custom-axis-tick";
import { CustomDot } from "./shared/custom-dot";
import { LineConfig, LineDataPoint } from "./types";
interface LineChartProps {
data: LineDataPoint[];
lines: LineConfig[];
height?: number;
xAxisInterval?: number | "preserveStart" | "preserveEnd" | "preserveStartEnd";
onPointClick?: (data: PointClickData) => void;
}
interface TooltipPayloadItem {
@@ -39,28 +43,54 @@ interface TooltipPayloadItem {
payload: LineDataPoint;
}
const formatTooltipDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
};
interface CustomLineTooltipProps extends TooltipProps<number, string> {
filterLine?: string | null;
}
const CustomLineTooltip = ({
active,
payload,
label,
}: TooltipProps<number, string>) => {
filterLine,
}: CustomLineTooltipProps) => {
if (!active || !payload || payload.length === 0) {
return null;
}
const typedPayload = payload as unknown as TooltipPayloadItem[];
const totalValue = typedPayload.reduce((sum, item) => sum + item.value, 0);
// Filter payload if a line is selected or hovered
const displayPayload = filterLine
? typedPayload.filter((item) => item.dataKey === filterLine)
: typedPayload;
if (displayPayload.length === 0) {
return null;
}
const totalValue = displayPayload.reduce((sum, item) => sum + item.value, 0);
const formattedDate = formatTooltipDate(String(label));
return (
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary pointer-events-none min-w-[200px] rounded-xl border p-3 shadow-lg">
<p className="text-text-neutral-secondary mb-3 text-xs">{label}</p>
<p className="text-text-neutral-secondary mb-3 text-xs">
{formattedDate}
</p>
<div className="mb-3">
<AlertPill value={totalValue} textSize="sm" />
</div>
<div className="space-y-3">
{typedPayload.map((item) => {
{displayPayload.map((item) => {
const newFindings = item.payload[`${item.dataKey}_newFindings`];
const change = item.payload[`${item.dataKey}_change`];
@@ -106,14 +136,30 @@ const chartConfig = {
},
} satisfies ChartConfig;
export function LineChart({ data, lines, height = 400 }: LineChartProps) {
export function LineChart({
data,
lines,
height = 400,
xAxisInterval = "preserveStartEnd",
onPointClick,
}: LineChartProps) {
const [hoveredLine, setHoveredLine] = useState<string | null>(null);
const [selectedLine, setSelectedLine] = useState<string | null>(null);
// Active line is either selected (persistent) or hovered (temporary)
const activeLine = selectedLine ?? hoveredLine;
const legendItems = lines.map((line) => ({
label: line.label,
color: line.color,
dataKey: line.dataKey,
}));
const handleLegendClick = (dataKey: string) => {
// Toggle selection: if already selected, deselect; otherwise select
setSelectedLine((current) => (current === dataKey ? null : dataKey));
};
return (
<div className="w-full">
<ChartContainer
@@ -126,9 +172,10 @@ export function LineChart({ data, lines, height = 400 }: LineChartProps) {
margin={{
top: 10,
left: 0,
right: 8,
bottom: 20,
right: 30,
bottom: 40,
}}
style={{ cursor: onPointClick ? "pointer" : "default" }}
>
<CartesianGrid
vertical={false}
@@ -140,7 +187,10 @@ export function LineChart({ data, lines, height = 400 }: LineChartProps) {
tickLine={false}
axisLine={false}
tickMargin={8}
tick={CustomXAxisTickWithToday}
interval={xAxisInterval}
tick={(props) => (
<CustomXAxisTickWithToday {...props} data={data} />
)}
/>
<YAxis
tickLine={false}
@@ -151,10 +201,17 @@ export function LineChart({ data, lines, height = 400 }: LineChartProps) {
fontSize: AXIS_FONT_SIZE,
}}
/>
<ChartTooltip cursor={false} content={<CustomLineTooltip />} />
<ChartTooltip
cursor={{
stroke: "var(--color-text-neutral-tertiary)",
strokeWidth: 1,
strokeDasharray: "4 4",
}}
content={<CustomLineTooltip filterLine={activeLine} />}
/>
{lines.map((line) => {
const isHovered = hoveredLine === line.dataKey;
const isFaded = hoveredLine !== null && !isHovered;
const isActive = activeLine === line.dataKey;
const isFaded = activeLine !== null && !isActive;
return (
<Line
key={line.dataKey}
@@ -162,12 +219,38 @@ export function LineChart({ data, lines, height = 400 }: LineChartProps) {
dataKey={line.dataKey}
stroke={line.color}
strokeWidth={2}
strokeOpacity={isFaded ? 0.5 : 1}
strokeOpacity={isFaded ? 0.2 : 1}
name={line.label}
dot={{ fill: line.color, r: 4 }}
activeDot={{ r: 6 }}
onMouseEnter={() => setHoveredLine(line.dataKey)}
onMouseLeave={() => setHoveredLine(null)}
dot={({
key,
...props
}: {
key?: string;
cx?: number;
cy?: number;
}) => (
<CustomDot
key={key}
{...props}
color={line.color}
isFaded={isFaded}
/>
)}
activeDot={(props: {
cx?: number;
cy?: number;
payload?: LineDataPoint;
}) => (
<CustomActiveDot
{...props}
dataKey={line.dataKey}
color={line.color}
isFaded={isFaded}
onPointClick={onPointClick}
onMouseEnter={() => setHoveredLine(line.dataKey)}
onMouseLeave={() => setHoveredLine(null)}
/>
)}
style={{ transition: "stroke-opacity 0.2s" }}
/>
);
@@ -175,8 +258,15 @@ export function LineChart({ data, lines, height = 400 }: LineChartProps) {
</RechartsLine>
</ChartContainer>
<div className="mt-4">
<ChartLegend items={legendItems} />
<div className="mt-4 flex flex-col items-start gap-2">
<p className="text-text-neutral-tertiary pl-2 text-xs">
Click to filter by severity.
</p>
<ChartLegend
items={legendItems}
selectedItem={selectedLine}
onItemClick={handleLegendClick}
/>
</div>
</div>
);

View File

@@ -1,29 +1,55 @@
export interface ChartLegendItem {
label: string;
color: string;
dataKey?: string;
}
interface ChartLegendProps {
items: ChartLegendItem[];
selectedItem?: string | null;
onItemClick?: (dataKey: string) => void;
}
export function ChartLegend({ items }: ChartLegendProps) {
export function ChartLegend({
items,
selectedItem,
onItemClick,
}: ChartLegendProps) {
const isInteractive = !!onItemClick;
return (
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary inline-flex items-center gap-2 rounded-full border">
{items.map((item, index) => (
<div
key={`legend-${index}`}
className="flex items-center gap-2 px-4 py-3"
>
<div
className="h-3 w-3 rounded"
style={{ backgroundColor: item.color }}
/>
<span className="text-text-neutral-secondary text-sm font-medium">
{item.label}
</span>
</div>
))}
{items.map((item, index) => {
const dataKey = item.dataKey ?? item.label.toLowerCase();
const isSelected = selectedItem === dataKey;
const isFaded = selectedItem !== null && !isSelected;
return (
<button
key={`legend-${index}`}
type="button"
className={`flex items-center gap-2 px-4 py-3 transition-opacity duration-200 ${
isInteractive
? "cursor-pointer hover:opacity-80"
: "cursor-default"
} ${isFaded ? "opacity-30" : "opacity-100"}`}
onClick={() => onItemClick?.(dataKey)}
disabled={!isInteractive}
>
<div
className={`h-3 w-3 rounded ${isSelected ? "ring-2 ring-offset-1" : ""}`}
style={{
backgroundColor: item.color,
// @ts-expect-error ring-color is a valid Tailwind CSS variable
"--tw-ring-color": item.color,
}}
/>
<span className="text-text-neutral-secondary text-sm font-medium">
{item.label}
</span>
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { Dot } from "recharts";
import { LineDataPoint } from "../types";
export interface PointClickData {
point: LineDataPoint;
dataKey?: string;
}
interface CustomActiveDotProps {
cx?: number;
cy?: number;
payload?: LineDataPoint;
dataKey: string;
color: string;
isFaded: boolean;
onPointClick?: (data: PointClickData) => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
}
export const CustomActiveDot = ({
cx,
cy,
payload,
dataKey,
color,
isFaded,
onPointClick,
onMouseEnter,
onMouseLeave,
}: CustomActiveDotProps) => {
if (cx === undefined || cy === undefined) return null;
// Don't render active dot for faded lines
if (isFaded) return null;
return (
<Dot
cx={cx}
cy={cy}
r={6}
fill={color}
style={{ cursor: onPointClick ? "pointer" : "default" }}
onClick={() => {
if (onPointClick && payload) {
onPointClick({ point: payload, dataKey });
}
}}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
/>
);
};

View File

@@ -1,27 +1,55 @@
export const AXIS_FONT_SIZE = 14;
const TODAY_FONT_SIZE = 12;
const MONTH_FONT_SIZE = 11;
interface CustomXAxisTickProps {
x: number;
y: number;
index?: number;
payload: {
value: string | number;
};
visibleTicksCount?: number;
}
const getTodayFormatted = () => {
const getTodayISO = () => {
const today = new Date();
return today.toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
});
return today.toISOString().split("T")[0];
};
const getMonthName = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString("en-US", { month: "short" });
};
const getDayNumber = (dateStr: string) => {
const date = new Date(dateStr);
return date.getDate();
};
const getMonthFromDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.getMonth();
};
export const CustomXAxisTickWithToday = Object.assign(
function CustomXAxisTickWithToday(props: CustomXAxisTickProps) {
const { x, y, payload } = props;
const todayFormatted = getTodayFormatted();
const isToday = String(payload.value) === todayFormatted;
function CustomXAxisTickWithToday(
props: CustomXAxisTickProps & { data?: Array<{ date: string }> },
) {
const { x, y, payload, index = 0, data = [] } = props;
const dateStr = String(payload.value);
const todayISO = getTodayISO();
const isToday = dateStr === todayISO;
const dayNumber = getDayNumber(dateStr);
const currentMonth = getMonthFromDate(dateStr);
// Show month name if it's the first tick or if the month changed from previous tick
const isFirstTick = index === 0;
const previousDate = index > 0 && data[index - 1]?.date;
const previousMonth = previousDate ? getMonthFromDate(previousDate) : -1;
const monthChanged = currentMonth !== previousMonth;
const showMonth = isFirstTick || monthChanged;
return (
<g transform={`translate(${x},${y})`}>
@@ -33,12 +61,23 @@ export const CustomXAxisTickWithToday = Object.assign(
fill="var(--color-text-neutral-secondary)"
fontSize={AXIS_FONT_SIZE}
>
{payload.value}
{dayNumber}
</text>
{showMonth && (
<text
x={0}
y={42}
textAnchor="middle"
fill="var(--color-text-neutral-tertiary)"
fontSize={MONTH_FONT_SIZE}
>
{getMonthName(dateStr)}
</text>
)}
{isToday && (
<text
x={0}
y={36}
y={showMonth ? 56 : 42}
textAnchor="middle"
fill="var(--color-text-neutral-secondary)"
fontSize={TODAY_FONT_SIZE}

View File

@@ -0,0 +1,14 @@
import { Dot } from "recharts";
interface CustomDotProps {
cx?: number;
cy?: number;
color: string;
isFaded: boolean;
}
export const CustomDot = ({ cx, cy, color, isFaded }: CustomDotProps) => {
if (cx === undefined || cy === undefined) return null;
return <Dot cx={cx} cy={cy} r={4} fill={color} opacity={isFaded ? 0.2 : 1} />;
};

View File

@@ -27,7 +27,7 @@ export interface DonutDataPoint {
export interface LineDataPoint {
date: string;
[key: string]: string | number;
[key: string]: string | number | string[];
}
export interface RadarDataPoint {
@@ -60,5 +60,5 @@ export interface TooltipData {
new?: number;
muted?: number;
change?: number;
[key: string]: any;
[key: string]: string | number | boolean | string[] | undefined;
}

View File

@@ -5,7 +5,7 @@ import {
getFindingsBySeverity,
getFindingsByStatus,
getProvidersOverview,
} from "@/actions/overview/overview";
} from "@/actions/overview";
import {
getFindingsBySeveritySchema,
getFindingsByStatusSchema,

View File

@@ -21,6 +21,30 @@ export const mapProviderFiltersForFindings = (
}
};
/**
* Maps overview provider filters to findings page provider filters (object version).
* Converts provider_id__in to provider__in and removes provider_type__in
* since provider__in is more specific.
*/
export const mapProviderFiltersForFindingsObject = <
T extends Record<string, unknown>,
>(
filters: T,
): T => {
const result = { ...filters };
const providerIdKey = "filter[provider_id__in]";
const providerTypeKey = "filter[provider_type__in]";
const providerKey = "filter[provider__in]";
if (providerIdKey in result) {
result[providerKey as keyof T] = result[providerIdKey as keyof T];
delete result[providerIdKey as keyof T];
delete result[providerTypeKey as keyof T];
}
return result;
};
export const extractProviderUIDs = (
providersData: ProvidersApiResponse,
): string[] => {

View File

@@ -25,6 +25,9 @@ export const SEVERITY_COLORS: Record<SeverityLevel, string> = {
informational: "var(--color-bg-data-info)",
};
// Muted color for charts - uses CSS var() for Recharts inline style compatibility (same pattern as SEVERITY_COLORS)
export const MUTED_COLOR = "var(--color-bg-data-muted)";
export const SEVERITY_FILTER_MAP: Record<string, SeverityLevel> = {
Critical: "critical",
High: "high",