mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-01-25 02:08:11 +00:00
feat(ui): add Finding Severity Over Time chart to overview page (#9405)
This commit is contained in:
@@ -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)
|
||||
|
||||
2
ui/actions/api-keys/index.ts
Normal file
2
ui/actions/api-keys/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./api-keys";
|
||||
export * from "./api-keys.adapter";
|
||||
@@ -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
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from "./compliances";
|
||||
export * from "./compliances.adapter";
|
||||
export type {
|
||||
ComplianceOverviewsResponse,
|
||||
EnrichedComplianceOverview,
|
||||
} from "./types";
|
||||
|
||||
33
ui/actions/compliances/types.ts
Normal file
33
ui/actions/compliances/types.ts
Normal 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;
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
AttackSurfaceOverview,
|
||||
AttackSurfaceOverviewResponse,
|
||||
} from "./types/attack-surface";
|
||||
import { AttackSurfaceOverview, AttackSurfaceOverviewResponse } from "../types";
|
||||
|
||||
const ATTACK_SURFACE_IDS = {
|
||||
INTERNET_EXPOSED: "internet-exposed",
|
||||
34
ui/actions/overview/attack-surface/attack-surface.ts
Normal file
34
ui/actions/overview/attack-surface/attack-surface.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
2
ui/actions/overview/attack-surface/index.ts
Normal file
2
ui/actions/overview/attack-surface/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./attack-surface";
|
||||
export * from "./attack-surface.adapter";
|
||||
86
ui/actions/overview/findings/findings.ts
Normal file
86
ui/actions/overview/findings/findings.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
1
ui/actions/overview/findings/index.ts
Normal file
1
ui/actions/overview/findings/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./findings";
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
2
ui/actions/overview/providers/index.ts
Normal file
2
ui/actions/overview/providers/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./providers";
|
||||
export * from "./sankey.adapter";
|
||||
47
ui/actions/overview/providers/providers.ts
Normal file
47
ui/actions/overview/providers/providers.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
2
ui/actions/overview/regions/index.ts
Normal file
2
ui/actions/overview/regions/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./regions";
|
||||
export * from "./threat-map.adapter";
|
||||
34
ui/actions/overview/regions/regions.ts
Normal file
34
ui/actions/overview/regions/regions.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getProviderDisplayName } from "@/types/providers";
|
||||
|
||||
import { RegionsOverviewResponse } from "./types";
|
||||
import { RegionsOverviewResponse } from "../types";
|
||||
|
||||
export interface ThreatMapLocation {
|
||||
id: string;
|
||||
1
ui/actions/overview/services/index.ts
Normal file
1
ui/actions/overview/services/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./services";
|
||||
33
ui/actions/overview/services/services.ts
Normal file
33
ui/actions/overview/services/services.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
2
ui/actions/overview/severity-trends/index.ts
Normal file
2
ui/actions/overview/severity-trends/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./severity-trends";
|
||||
export * from "./severity-trends.adapter";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
99
ui/actions/overview/severity-trends/severity-trends.ts
Normal file
99
ui/actions/overview/severity-trends/severity-trends.ts
Normal 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 };
|
||||
1
ui/actions/overview/threat-score/index.ts
Normal file
1
ui/actions/overview/threat-score/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./threat-score";
|
||||
32
ui/actions/overview/threat-score/threat-score.ts
Normal file
32
ui/actions/overview/threat-score/threat-score.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
33
ui/actions/overview/types/severity-trends.ts
Normal file
33
ui/actions/overview/types/severity-trends.ts
Normal 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;
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./poll";
|
||||
export * from "./tasks";
|
||||
|
||||
2
ui/actions/users/index.ts
Normal file
2
ui/actions/users/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./tenants";
|
||||
export * from "./users";
|
||||
9
ui/app/(prowler)/_new-overview/_types.ts
Normal file
9
ui/app/(prowler)/_new-overview/_types.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 });
|
||||
@@ -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";
|
||||
@@ -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 });
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -1,5 +0,0 @@
|
||||
export {
|
||||
RiskSeverityChart,
|
||||
RiskSeverityChartSkeleton,
|
||||
} from "./risk-severity-chart";
|
||||
export { RiskSeverityChartSSR } from "./risk-severity-chart.ssr";
|
||||
@@ -1 +0,0 @@
|
||||
export { StatusChart, StatusChartSkeleton } from "./status-chart";
|
||||
@@ -1,2 +0,0 @@
|
||||
export { ThreatScore, ThreatScoreSkeleton } from "./threat-score";
|
||||
export { ThreatScoreSSR } from "./threat-score.ssr";
|
||||
@@ -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";
|
||||
@@ -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>;
|
||||
@@ -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({
|
||||
@@ -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
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
3
ui/app/(prowler)/_new-overview/risk-severity/index.ts
Normal file
3
ui/app/(prowler)/_new-overview/risk-severity/index.ts
Normal 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";
|
||||
@@ -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";
|
||||
@@ -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} />;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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",
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
2
ui/app/(prowler)/_new-overview/status-chart/index.ts
Normal file
2
ui/app/(prowler)/_new-overview/status-chart/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { StatusChart } from "./_components/status-chart";
|
||||
export { StatusChartSkeleton } from "./_components/status-chart.skeleton";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
3
ui/app/(prowler)/_new-overview/threat-score/index.ts
Normal file
3
ui/app/(prowler)/_new-overview/threat-score/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { ThreatScore } from "./_components/threat-score";
|
||||
export { ThreatScoreSkeleton } from "./_components/threat-score.skeleton";
|
||||
export { ThreatScoreSSR } from "./threat-score.ssr";
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
12
ui/app/(prowler)/_new-overview/watchlist/index.ts
Normal file
12
ui/app/(prowler)/_new-overview/watchlist/index.ts
Normal 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";
|
||||
@@ -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 });
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
54
ui/components/graphs/shared/custom-active-dot.tsx
Normal file
54
ui/components/graphs/shared/custom-active-dot.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
14
ui/components/graphs/shared/custom-dot.tsx
Normal file
14
ui/components/graphs/shared/custom-dot.tsx
Normal 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} />;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
getFindingsBySeverity,
|
||||
getFindingsByStatus,
|
||||
getProvidersOverview,
|
||||
} from "@/actions/overview/overview";
|
||||
} from "@/actions/overview";
|
||||
import {
|
||||
getFindingsBySeveritySchema,
|
||||
getFindingsByStatusSchema,
|
||||
|
||||
@@ -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[] => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user