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
|
### 🚀 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)
|
- 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)
|
- 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)
|
- 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 { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
|
||||||
import { MetaDataProps } from "@/types";
|
import { MetaDataProps } from "@/types";
|
||||||
import { ComplianceOverviewData } from "@/types/compliance";
|
|
||||||
|
|
||||||
/**
|
import {
|
||||||
* Raw API response from /compliance-overviews endpoint
|
ComplianceOverviewsResponse,
|
||||||
*/
|
EnrichedComplianceOverview,
|
||||||
export interface ComplianceOverviewsResponse {
|
} from "./types";
|
||||||
data: ComplianceOverviewData[];
|
|
||||||
meta?: {
|
|
||||||
pagination?: {
|
|
||||||
page: number;
|
|
||||||
pages: number;
|
|
||||||
count: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
export type { ComplianceOverviewsResponse, EnrichedComplianceOverview };
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats framework name for display by replacing hyphens with spaces
|
* Formats framework name for display by replacing hyphens with spaces
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
export * from "./compliances";
|
export * from "./compliances";
|
||||||
export * from "./compliances.adapter";
|
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 {
|
import { AttackSurfaceOverview, AttackSurfaceOverviewResponse } from "../types";
|
||||||
AttackSurfaceOverview,
|
|
||||||
AttackSurfaceOverviewResponse,
|
|
||||||
} from "./types/attack-surface";
|
|
||||||
|
|
||||||
const ATTACK_SURFACE_IDS = {
|
const ATTACK_SURFACE_IDS = {
|
||||||
INTERNET_EXPOSED: "internet-exposed",
|
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";
|
// Re-export all overview actions from feature-based subfolders
|
||||||
export * from "./overview";
|
export * from "./attack-surface";
|
||||||
export * from "./sankey.adapter";
|
export * from "./findings";
|
||||||
export * from "./threat-map.adapter";
|
export * from "./providers";
|
||||||
|
export * from "./regions";
|
||||||
|
export * from "./services";
|
||||||
|
export * from "./severity-trends";
|
||||||
|
export * from "./threat-score";
|
||||||
export * from "./types";
|
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 { getProviderDisplayName } from "@/types/providers";
|
||||||
|
|
||||||
import { RegionsOverviewResponse } from "./types";
|
import { RegionsOverviewResponse } from "../types";
|
||||||
|
|
||||||
export interface ThreatMapLocation {
|
export interface ThreatMapLocation {
|
||||||
id: string;
|
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 "./providers";
|
||||||
export * from "./regions";
|
export * from "./regions";
|
||||||
export * from "./services";
|
export * from "./services";
|
||||||
|
export * from "./severity-trends";
|
||||||
export * from "./threat-score";
|
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";
|
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 { Card, CardContent, CardTitle } from "@/components/shadcn";
|
||||||
|
|
||||||
import { AttackSurfaceCardItem } from "./attack-surface-card-item";
|
import { AttackSurfaceCardItem } from "./attack-surface-card-item";
|
||||||
@@ -2,16 +2,12 @@ import {
|
|||||||
adaptAttackSurfaceOverview,
|
adaptAttackSurfaceOverview,
|
||||||
getAttackSurfaceOverview,
|
getAttackSurfaceOverview,
|
||||||
} from "@/actions/overview";
|
} from "@/actions/overview";
|
||||||
import { SearchParamsProps } from "@/types";
|
|
||||||
|
|
||||||
import { pickFilterParams } from "../../lib/filter-params";
|
import { pickFilterParams } from "../_lib/filter-params";
|
||||||
import { AttackSurface } from "./attack-surface";
|
import { SSRComponentProps } from "../_types";
|
||||||
|
import { AttackSurface } from "./_components/attack-surface";
|
||||||
|
|
||||||
export const AttackSurfaceSSR = async ({
|
export const AttackSurfaceSSR = async ({ searchParams }: SSRComponentProps) => {
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: SearchParamsProps | undefined | null;
|
|
||||||
}) => {
|
|
||||||
const filters = pickFilterParams(searchParams);
|
const filters = pickFilterParams(searchParams);
|
||||||
|
|
||||||
const response = await getAttackSurfaceOverview({ filters });
|
const response = await getAttackSurfaceOverview({ filters });
|
||||||
@@ -1,4 +1,2 @@
|
|||||||
export { AttackSurface } from "./attack-surface";
|
|
||||||
export { AttackSurfaceSSR } from "./attack-surface.ssr";
|
export { AttackSurfaceSSR } from "./attack-surface.ssr";
|
||||||
export { AttackSurfaceCardItem } from "./attack-surface-card-item";
|
|
||||||
export { AttackSurfaceSkeleton } from "./attack-surface-skeleton";
|
export { AttackSurfaceSkeleton } from "./attack-surface-skeleton";
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
import { getFindingsByStatus } from "@/actions/overview/overview";
|
import { getFindingsByStatus } from "@/actions/overview";
|
||||||
import { SearchParamsProps } from "@/types";
|
|
||||||
|
|
||||||
import { pickFilterParams } from "../../lib/filter-params";
|
import { pickFilterParams } from "../_lib/filter-params";
|
||||||
import { StatusChart } from "../status-chart/status-chart";
|
import { SSRComponentProps } from "../_types";
|
||||||
|
import { StatusChart } from "../status-chart/_components/status-chart";
|
||||||
|
|
||||||
export const CheckFindingsSSR = async ({
|
export const CheckFindingsSSR = async ({ searchParams }: SSRComponentProps) => {
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: SearchParamsProps | undefined | null;
|
|
||||||
}) => {
|
|
||||||
const filters = pickFilterParams(searchParams);
|
const filters = pickFilterParams(searchParams);
|
||||||
|
|
||||||
const findingsByStatus = await getFindingsByStatus({ filters });
|
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 { 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 {
|
interface GraphsTabsClientProps {
|
||||||
tabsContent: Record<TabId, React.ReactNode>;
|
tabsContent: Record<TabId, React.ReactNode>;
|
||||||
@@ -3,24 +3,15 @@
|
|||||||
import { Spacer } from "@heroui/spacer";
|
import { Spacer } from "@heroui/spacer";
|
||||||
|
|
||||||
import { getLatestFindings } from "@/actions/findings/findings";
|
import { getLatestFindings } from "@/actions/findings/findings";
|
||||||
|
import { LighthouseBanner } from "@/components/lighthouse/banner";
|
||||||
import { LinkToFindings } from "@/components/overview";
|
import { LinkToFindings } from "@/components/overview";
|
||||||
import { ColumnNewFindingsToDate } from "@/components/overview/new-findings-table/table/column-new-findings-to-date";
|
import { ColumnNewFindingsToDate } from "@/components/overview/new-findings-table/table/column-new-findings-to-date";
|
||||||
import { DataTable } from "@/components/ui/table";
|
import { DataTable } from "@/components/ui/table";
|
||||||
import { createDict } from "@/lib/helper";
|
import { createDict } from "@/lib/helper";
|
||||||
|
import { mapProviderFiltersForFindingsObject } from "@/lib/provider-helpers";
|
||||||
import { FindingProps, SearchParamsProps } from "@/types";
|
import { FindingProps, SearchParamsProps } from "@/types";
|
||||||
|
|
||||||
import { LighthouseBanner } from "../../../../../../components/lighthouse/banner";
|
import { pickFilterParams } from "../../_lib/filter-params";
|
||||||
|
|
||||||
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)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FindingsViewSSRProps {
|
interface FindingsViewSSRProps {
|
||||||
searchParams: SearchParamsProps;
|
searchParams: SearchParamsProps;
|
||||||
@@ -36,15 +27,7 @@ export async function FindingsViewSSR({ searchParams }: FindingsViewSSRProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const filters = pickFilterParams(searchParams);
|
const filters = pickFilterParams(searchParams);
|
||||||
|
const mappedFilters = mapProviderFiltersForFindingsObject(filters);
|
||||||
// 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 combinedFilters = { ...defaultFilters, ...mappedFilters };
|
const combinedFilters = { ...defaultFilters, ...mappedFilters };
|
||||||
|
|
||||||
const findingsData = await getLatestFindings({
|
const findingsData = await getLatestFindings({
|
||||||
@@ -3,9 +3,9 @@ import { Suspense } from "react";
|
|||||||
|
|
||||||
import { SearchParamsProps } from "@/types";
|
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 { 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 { RiskPipelineViewSSR } from "./risk-pipeline-view/risk-pipeline-view.ssr";
|
||||||
import { ThreatMapViewSSR } from "./threat-map-view/threat-map-view.ssr";
|
import { ThreatMapViewSSR } from "./threat-map-view/threat-map-view.ssr";
|
||||||
// TODO: Uncomment when ready to enable other tabs
|
// 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 { SankeyChart } from "@/components/graphs/sankey-chart";
|
||||||
import { SearchParamsProps } from "@/types";
|
import { SearchParamsProps } from "@/types";
|
||||||
|
|
||||||
import { pickFilterParams } from "../../../lib/filter-params";
|
import { pickFilterParams } from "../../_lib/filter-params";
|
||||||
|
|
||||||
export async function RiskPipelineViewSSR({
|
export async function RiskPipelineViewSSR({
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
import { ThreatMap } from "@/components/graphs/threat-map";
|
import { ThreatMap } from "@/components/graphs/threat-map";
|
||||||
import { SearchParamsProps } from "@/types";
|
import { SearchParamsProps } from "@/types";
|
||||||
|
|
||||||
import { pickFilterParams } from "../../../lib/filter-params";
|
import { pickFilterParams } from "../../_lib/filter-params";
|
||||||
|
|
||||||
export async function ThreatMapViewSSR({
|
export async function ThreatMapViewSSR({
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -4,23 +4,23 @@ import { getProviders } from "@/actions/providers";
|
|||||||
import { ContentLayout } from "@/components/ui";
|
import { ContentLayout } from "@/components/ui";
|
||||||
import { SearchParamsProps } from "@/types";
|
import { SearchParamsProps } from "@/types";
|
||||||
|
|
||||||
import { AccountsSelector } from "./components/accounts-selector";
|
import { AccountsSelector } from "./_components/accounts-selector";
|
||||||
import { CheckFindingsSSR } from "./components/check-findings";
|
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 {
|
import {
|
||||||
FindingSeverityOverTimeSkeleton,
|
FindingSeverityOverTimeSkeleton,
|
||||||
FindingSeverityOverTimeSSR,
|
FindingSeverityOverTimeSSR,
|
||||||
} from "./components/finding-severity-over-time/finding-severity-over-time.ssr";
|
} from "./severity-over-time/finding-severity-over-time.ssr";
|
||||||
import { GraphsTabsWrapper } from "./components/graphs-tabs/graphs-tabs-wrapper";
|
import { StatusChartSkeleton } from "./status-chart";
|
||||||
import { ProviderTypeSelector } from "./components/provider-type-selector";
|
import { ThreatScoreSkeleton, ThreatScoreSSR } from "./threat-score";
|
||||||
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";
|
|
||||||
import {
|
import {
|
||||||
ComplianceWatchlistSSR,
|
ComplianceWatchlistSSR,
|
||||||
ServiceWatchlistSSR,
|
ServiceWatchlistSSR,
|
||||||
WatchlistCardSkeleton,
|
WatchlistCardSkeleton,
|
||||||
} from "./components/watchlist";
|
} from "./watchlist";
|
||||||
|
|
||||||
export default async function NewOverviewPage({
|
export default async function NewOverviewPage({
|
||||||
searchParams,
|
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 { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart";
|
||||||
import { BarDataPoint } from "@/components/graphs/types";
|
import { BarDataPoint } from "@/components/graphs/types";
|
||||||
import {
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
Skeleton,
|
|
||||||
} from "@/components/shadcn";
|
|
||||||
import { mapProviderFiltersForFindings } from "@/lib/provider-helpers";
|
import { mapProviderFiltersForFindings } from "@/lib/provider-helpers";
|
||||||
import { calculatePercentage } from "@/lib/utils";
|
import { calculatePercentage } from "@/lib/utils";
|
||||||
import { SEVERITY_FILTER_MAP } from "@/types/severities";
|
import { SEVERITY_FILTER_MAP } from "@/types/severities";
|
||||||
@@ -100,28 +94,3 @@ export const RiskSeverityChart = ({
|
|||||||
</Card>
|
</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 { getFindingsBySeverity } from "@/actions/overview";
|
||||||
import { SearchParamsProps } from "@/types";
|
|
||||||
|
|
||||||
import { pickFilterParams } from "../../lib/filter-params";
|
import { pickFilterParams } from "../_lib/filter-params";
|
||||||
import { RiskSeverityChart } from "./risk-severity-chart";
|
import { SSRComponentProps } from "../_types";
|
||||||
|
import { RiskSeverityChart } from "./_components/risk-severity-chart";
|
||||||
|
|
||||||
export const RiskSeverityChartDetailSSR = async ({
|
export const RiskSeverityChartDetailSSR = async ({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: SSRComponentProps) => {
|
||||||
searchParams: SearchParamsProps | undefined | null;
|
|
||||||
}) => {
|
|
||||||
const filters = pickFilterParams(searchParams);
|
const filters = pickFilterParams(searchParams);
|
||||||
// Filter by FAIL findings
|
// Filter by FAIL findings
|
||||||
filters["filter[status]"] = "FAIL";
|
filters["filter[status]"] = "FAIL";
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
import { SearchParamsProps } from "@/types";
|
import { pickFilterParams } from "../_lib/filter-params";
|
||||||
|
import { SSRComponentProps } from "../_types";
|
||||||
import { pickFilterParams } from "../../lib/filter-params";
|
|
||||||
import { RiskSeverityChartDetailSSR } from "./risk-severity-chart-detail.ssr";
|
import { RiskSeverityChartDetailSSR } from "./risk-severity-chart-detail.ssr";
|
||||||
|
|
||||||
export const RiskSeverityChartSSR = async ({
|
export const RiskSeverityChartSSR = async ({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: SSRComponentProps) => {
|
||||||
searchParams: SearchParamsProps | undefined | null;
|
|
||||||
}) => {
|
|
||||||
const filters = pickFilterParams(searchParams);
|
const filters = pickFilterParams(searchParams);
|
||||||
|
|
||||||
return <RiskSeverityChartDetailSSR searchParams={filters} />;
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const TIME_RANGE_OPTIONS = {
|
const TIME_RANGE_OPTIONS = {
|
||||||
ONE_DAY: "1D",
|
|
||||||
FIVE_DAYS: "5D",
|
FIVE_DAYS: "5D",
|
||||||
ONE_WEEK: "1W",
|
ONE_WEEK: "1W",
|
||||||
ONE_MONTH: "1M",
|
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,
|
CardTitle,
|
||||||
CardVariant,
|
CardVariant,
|
||||||
ResourceStatsCard,
|
ResourceStatsCard,
|
||||||
Skeleton,
|
|
||||||
} from "@/components/shadcn";
|
} from "@/components/shadcn";
|
||||||
import { mapProviderFiltersForFindings } from "@/lib/provider-helpers";
|
import { mapProviderFiltersForFindings } from "@/lib/provider-helpers";
|
||||||
import { calculatePercentage } from "@/lib/utils";
|
import { calculatePercentage } from "@/lib/utils";
|
||||||
@@ -165,26 +164,3 @@ export const StatusChart = ({
|
|||||||
</Card>
|
</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,
|
SectionScores,
|
||||||
} from "@/actions/overview/types";
|
} from "@/actions/overview/types";
|
||||||
import { RadialChart } from "@/components/graphs/radial-chart";
|
import { RadialChart } from "@/components/graphs/radial-chart";
|
||||||
import {
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
Skeleton,
|
|
||||||
} from "@/components/shadcn";
|
|
||||||
|
|
||||||
// CSS variables are required here as they're passed to RadialChart component
|
// CSS variables are required here as they're passed to RadialChart component
|
||||||
// which uses Recharts library that needs actual color values, not Tailwind classes
|
// which uses Recharts library that needs actual color values, not Tailwind classes
|
||||||
@@ -119,7 +113,7 @@ export function ThreatScore({
|
|||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
variant="base"
|
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>
|
<CardHeader>
|
||||||
<CardTitle>Prowler Threat Score</CardTitle>
|
<CardTitle>Prowler Threat Score</CardTitle>
|
||||||
@@ -208,26 +202,3 @@ export function ThreatScore({
|
|||||||
</Card>
|
</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 { getThreatScore } from "@/actions/overview";
|
||||||
import { SearchParamsProps } from "@/types";
|
|
||||||
|
|
||||||
import { pickFilterParams } from "../../lib/filter-params";
|
import { pickFilterParams } from "../_lib/filter-params";
|
||||||
import { ThreatScore } from "./threat-score";
|
import { SSRComponentProps } from "../_types";
|
||||||
|
import { ThreatScore } from "./_components/threat-score";
|
||||||
|
|
||||||
export const ThreatScoreSSR = async ({
|
export const ThreatScoreSSR = async ({ searchParams }: SSRComponentProps) => {
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: SearchParamsProps | undefined | null;
|
|
||||||
}) => {
|
|
||||||
const filters = pickFilterParams(searchParams);
|
const filters = pickFilterParams(searchParams);
|
||||||
const threatScoreData = await getThreatScore({ filters });
|
const threatScoreData = await getThreatScore({ filters });
|
||||||
|
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
CardFooter,
|
CardFooter,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/shadcn/card/card";
|
} from "@/components/shadcn/card/card";
|
||||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const SCORE_CONFIG = {
|
const SCORE_CONFIG = {
|
||||||
@@ -192,23 +191,3 @@ export const WatchlistCard = ({
|
|||||||
</Card>
|
</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,
|
adaptComplianceOverviewsResponse,
|
||||||
getCompliancesOverview,
|
getCompliancesOverview,
|
||||||
} from "@/actions/compliances";
|
} from "@/actions/compliances";
|
||||||
import { SearchParamsProps } from "@/types";
|
|
||||||
|
|
||||||
import { pickFilterParams } from "../../lib/filter-params";
|
import { pickFilterParams } from "../_lib/filter-params";
|
||||||
import { ComplianceWatchlist } from "./compliance-watchlist";
|
import { SSRComponentProps } from "../_types";
|
||||||
|
import { ComplianceWatchlist } from "./_components/compliance-watchlist";
|
||||||
|
|
||||||
export const ComplianceWatchlistSSR = async ({
|
export const ComplianceWatchlistSSR = async ({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: SSRComponentProps) => {
|
||||||
searchParams: SearchParamsProps | undefined | null;
|
|
||||||
}) => {
|
|
||||||
const filters = pickFilterParams(searchParams);
|
const filters = pickFilterParams(searchParams);
|
||||||
|
|
||||||
const response = await getCompliancesOverview({ filters });
|
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 { getServicesOverview, ServiceOverview } from "@/actions/overview";
|
||||||
import { SearchParamsProps } from "@/types";
|
|
||||||
|
|
||||||
import { pickFilterParams } from "../../lib/filter-params";
|
import { pickFilterParams } from "../_lib/filter-params";
|
||||||
import { ServiceWatchlist } from "./service-watchlist";
|
import { SSRComponentProps } from "../_types";
|
||||||
|
import { ServiceWatchlist } from "./_components/service-watchlist";
|
||||||
|
|
||||||
export const ServiceWatchlistSSR = async ({
|
export const ServiceWatchlistSSR = async ({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: SSRComponentProps) => {
|
||||||
searchParams: SearchParamsProps | undefined | null;
|
|
||||||
}) => {
|
|
||||||
const filters = pickFilterParams(searchParams);
|
const filters = pickFilterParams(searchParams);
|
||||||
|
|
||||||
const response = await getServicesOverview({ filters });
|
const response = await getServicesOverview({ filters });
|
||||||
@@ -4,28 +4,32 @@ import { getProviders } from "@/actions/providers";
|
|||||||
import { ContentLayout } from "@/components/ui";
|
import { ContentLayout } from "@/components/ui";
|
||||||
import { SearchParamsProps } from "@/types";
|
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 {
|
import {
|
||||||
AttackSurfaceSkeleton,
|
AttackSurfaceSkeleton,
|
||||||
AttackSurfaceSSR,
|
AttackSurfaceSSR,
|
||||||
} from "./_new-overview/components/attack-surface";
|
} from "./_new-overview/attack-surface";
|
||||||
import { CheckFindingsSSR } from "./_new-overview/components/check-findings";
|
import { CheckFindingsSSR } from "./_new-overview/check-findings";
|
||||||
import { GraphsTabsWrapper } from "./_new-overview/components/graphs-tabs/graphs-tabs-wrapper";
|
import { GraphsTabsWrapper } from "./_new-overview/graphs-tabs/graphs-tabs-wrapper";
|
||||||
import { RiskPipelineViewSkeleton } from "./_new-overview/components/graphs-tabs/risk-pipeline-view";
|
import { RiskPipelineViewSkeleton } from "./_new-overview/graphs-tabs/risk-pipeline-view";
|
||||||
import { ProviderTypeSelector } from "./_new-overview/components/provider-type-selector";
|
|
||||||
import {
|
import {
|
||||||
RiskSeverityChartSkeleton,
|
RiskSeverityChartSkeleton,
|
||||||
RiskSeverityChartSSR,
|
RiskSeverityChartSSR,
|
||||||
} from "./_new-overview/components/risk-severity-chart";
|
} from "./_new-overview/risk-severity";
|
||||||
import { StatusChartSkeleton } from "./_new-overview/components/status-chart";
|
import {
|
||||||
|
FindingSeverityOverTimeSkeleton,
|
||||||
|
FindingSeverityOverTimeSSR,
|
||||||
|
} from "./_new-overview/severity-over-time/finding-severity-over-time.ssr";
|
||||||
|
import { StatusChartSkeleton } from "./_new-overview/status-chart";
|
||||||
import {
|
import {
|
||||||
ThreatScoreSkeleton,
|
ThreatScoreSkeleton,
|
||||||
ThreatScoreSSR,
|
ThreatScoreSSR,
|
||||||
} from "./_new-overview/components/threat-score";
|
} from "./_new-overview/threat-score";
|
||||||
import {
|
import {
|
||||||
ServiceWatchlistSSR,
|
ServiceWatchlistSSR,
|
||||||
WatchlistCardSkeleton,
|
WatchlistCardSkeleton,
|
||||||
} from "./_new-overview/components/watchlist";
|
} from "./_new-overview/watchlist";
|
||||||
|
|
||||||
export default async function Home({
|
export default async function Home({
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -42,7 +46,7 @@ export default async function Home({
|
|||||||
<AccountsSelector providers={providersData?.data ?? []} />
|
<AccountsSelector providers={providersData?.data ?? []} />
|
||||||
</div>
|
</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 />}>
|
<Suspense fallback={<ThreatScoreSkeleton />}>
|
||||||
<ThreatScoreSSR searchParams={resolvedSearchParams} />
|
<ThreatScoreSSR searchParams={resolvedSearchParams} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
@@ -62,10 +66,13 @@ export default async function Home({
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</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 />}>
|
<Suspense fallback={<WatchlistCardSkeleton />}>
|
||||||
<ServiceWatchlistSSR searchParams={resolvedSearchParams} />
|
<ServiceWatchlistSSR searchParams={resolvedSearchParams} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
<Suspense fallback={<FindingSeverityOverTimeSkeleton />}>
|
||||||
|
<FindingSeverityOverTimeSSR searchParams={resolvedSearchParams} />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Button, ButtonGroup } from "@heroui/button";
|
|||||||
import { DatePicker } from "@heroui/date-picker";
|
import { DatePicker } from "@heroui/date-picker";
|
||||||
import {
|
import {
|
||||||
getLocalTimeZone,
|
getLocalTimeZone,
|
||||||
|
parseDate,
|
||||||
startOfMonth,
|
startOfMonth,
|
||||||
startOfWeek,
|
startOfWeek,
|
||||||
today,
|
today,
|
||||||
@@ -20,7 +21,12 @@ export const CustomDatePicker = () => {
|
|||||||
|
|
||||||
const [value, setValue] = React.useState(() => {
|
const [value, setValue] = React.useState(() => {
|
||||||
const dateParam = searchParams.get("filter[inserted_at]");
|
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();
|
const { locale } = useLocale();
|
||||||
|
|||||||
@@ -19,16 +19,20 @@ import {
|
|||||||
|
|
||||||
import { AlertPill } from "./shared/alert-pill";
|
import { AlertPill } from "./shared/alert-pill";
|
||||||
import { ChartLegend } from "./shared/chart-legend";
|
import { ChartLegend } from "./shared/chart-legend";
|
||||||
|
import { CustomActiveDot, PointClickData } from "./shared/custom-active-dot";
|
||||||
import {
|
import {
|
||||||
AXIS_FONT_SIZE,
|
AXIS_FONT_SIZE,
|
||||||
CustomXAxisTickWithToday,
|
CustomXAxisTickWithToday,
|
||||||
} from "./shared/custom-axis-tick";
|
} from "./shared/custom-axis-tick";
|
||||||
|
import { CustomDot } from "./shared/custom-dot";
|
||||||
import { LineConfig, LineDataPoint } from "./types";
|
import { LineConfig, LineDataPoint } from "./types";
|
||||||
|
|
||||||
interface LineChartProps {
|
interface LineChartProps {
|
||||||
data: LineDataPoint[];
|
data: LineDataPoint[];
|
||||||
lines: LineConfig[];
|
lines: LineConfig[];
|
||||||
height?: number;
|
height?: number;
|
||||||
|
xAxisInterval?: number | "preserveStart" | "preserveEnd" | "preserveStartEnd";
|
||||||
|
onPointClick?: (data: PointClickData) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TooltipPayloadItem {
|
interface TooltipPayloadItem {
|
||||||
@@ -39,28 +43,54 @@ interface TooltipPayloadItem {
|
|||||||
payload: LineDataPoint;
|
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 = ({
|
const CustomLineTooltip = ({
|
||||||
active,
|
active,
|
||||||
payload,
|
payload,
|
||||||
label,
|
label,
|
||||||
}: TooltipProps<number, string>) => {
|
filterLine,
|
||||||
|
}: CustomLineTooltipProps) => {
|
||||||
if (!active || !payload || payload.length === 0) {
|
if (!active || !payload || payload.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const typedPayload = payload as unknown as TooltipPayloadItem[];
|
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 (
|
return (
|
||||||
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary pointer-events-none min-w-[200px] rounded-xl border p-3 shadow-lg">
|
<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">
|
<div className="mb-3">
|
||||||
<AlertPill value={totalValue} textSize="sm" />
|
<AlertPill value={totalValue} textSize="sm" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{typedPayload.map((item) => {
|
{displayPayload.map((item) => {
|
||||||
const newFindings = item.payload[`${item.dataKey}_newFindings`];
|
const newFindings = item.payload[`${item.dataKey}_newFindings`];
|
||||||
const change = item.payload[`${item.dataKey}_change`];
|
const change = item.payload[`${item.dataKey}_change`];
|
||||||
|
|
||||||
@@ -106,14 +136,30 @@ const chartConfig = {
|
|||||||
},
|
},
|
||||||
} satisfies 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 [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) => ({
|
const legendItems = lines.map((line) => ({
|
||||||
label: line.label,
|
label: line.label,
|
||||||
color: line.color,
|
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 (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
@@ -126,9 +172,10 @@ export function LineChart({ data, lines, height = 400 }: LineChartProps) {
|
|||||||
margin={{
|
margin={{
|
||||||
top: 10,
|
top: 10,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 8,
|
right: 30,
|
||||||
bottom: 20,
|
bottom: 40,
|
||||||
}}
|
}}
|
||||||
|
style={{ cursor: onPointClick ? "pointer" : "default" }}
|
||||||
>
|
>
|
||||||
<CartesianGrid
|
<CartesianGrid
|
||||||
vertical={false}
|
vertical={false}
|
||||||
@@ -140,7 +187,10 @@ export function LineChart({ data, lines, height = 400 }: LineChartProps) {
|
|||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickMargin={8}
|
tickMargin={8}
|
||||||
tick={CustomXAxisTickWithToday}
|
interval={xAxisInterval}
|
||||||
|
tick={(props) => (
|
||||||
|
<CustomXAxisTickWithToday {...props} data={data} />
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
@@ -151,10 +201,17 @@ export function LineChart({ data, lines, height = 400 }: LineChartProps) {
|
|||||||
fontSize: AXIS_FONT_SIZE,
|
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) => {
|
{lines.map((line) => {
|
||||||
const isHovered = hoveredLine === line.dataKey;
|
const isActive = activeLine === line.dataKey;
|
||||||
const isFaded = hoveredLine !== null && !isHovered;
|
const isFaded = activeLine !== null && !isActive;
|
||||||
return (
|
return (
|
||||||
<Line
|
<Line
|
||||||
key={line.dataKey}
|
key={line.dataKey}
|
||||||
@@ -162,12 +219,38 @@ export function LineChart({ data, lines, height = 400 }: LineChartProps) {
|
|||||||
dataKey={line.dataKey}
|
dataKey={line.dataKey}
|
||||||
stroke={line.color}
|
stroke={line.color}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
strokeOpacity={isFaded ? 0.5 : 1}
|
strokeOpacity={isFaded ? 0.2 : 1}
|
||||||
name={line.label}
|
name={line.label}
|
||||||
dot={{ fill: line.color, r: 4 }}
|
dot={({
|
||||||
activeDot={{ r: 6 }}
|
key,
|
||||||
onMouseEnter={() => setHoveredLine(line.dataKey)}
|
...props
|
||||||
onMouseLeave={() => setHoveredLine(null)}
|
}: {
|
||||||
|
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" }}
|
style={{ transition: "stroke-opacity 0.2s" }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -175,8 +258,15 @@ export function LineChart({ data, lines, height = 400 }: LineChartProps) {
|
|||||||
</RechartsLine>
|
</RechartsLine>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4 flex flex-col items-start gap-2">
|
||||||
<ChartLegend items={legendItems} />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,29 +1,55 @@
|
|||||||
export interface ChartLegendItem {
|
export interface ChartLegendItem {
|
||||||
label: string;
|
label: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
dataKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChartLegendProps {
|
interface ChartLegendProps {
|
||||||
items: ChartLegendItem[];
|
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 (
|
return (
|
||||||
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary inline-flex items-center gap-2 rounded-full border">
|
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary inline-flex items-center gap-2 rounded-full border">
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => {
|
||||||
<div
|
const dataKey = item.dataKey ?? item.label.toLowerCase();
|
||||||
key={`legend-${index}`}
|
const isSelected = selectedItem === dataKey;
|
||||||
className="flex items-center gap-2 px-4 py-3"
|
const isFaded = selectedItem !== null && !isSelected;
|
||||||
>
|
|
||||||
<div
|
return (
|
||||||
className="h-3 w-3 rounded"
|
<button
|
||||||
style={{ backgroundColor: item.color }}
|
key={`legend-${index}`}
|
||||||
/>
|
type="button"
|
||||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
className={`flex items-center gap-2 px-4 py-3 transition-opacity duration-200 ${
|
||||||
{item.label}
|
isInteractive
|
||||||
</span>
|
? "cursor-pointer hover:opacity-80"
|
||||||
</div>
|
: "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>
|
</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;
|
export const AXIS_FONT_SIZE = 14;
|
||||||
const TODAY_FONT_SIZE = 12;
|
const TODAY_FONT_SIZE = 12;
|
||||||
|
const MONTH_FONT_SIZE = 11;
|
||||||
|
|
||||||
interface CustomXAxisTickProps {
|
interface CustomXAxisTickProps {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
|
index?: number;
|
||||||
payload: {
|
payload: {
|
||||||
value: string | number;
|
value: string | number;
|
||||||
};
|
};
|
||||||
|
visibleTicksCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTodayFormatted = () => {
|
const getTodayISO = () => {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
return today.toLocaleDateString("en-US", {
|
return today.toISOString().split("T")[0];
|
||||||
month: "2-digit",
|
};
|
||||||
day: "2-digit",
|
|
||||||
});
|
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(
|
export const CustomXAxisTickWithToday = Object.assign(
|
||||||
function CustomXAxisTickWithToday(props: CustomXAxisTickProps) {
|
function CustomXAxisTickWithToday(
|
||||||
const { x, y, payload } = props;
|
props: CustomXAxisTickProps & { data?: Array<{ date: string }> },
|
||||||
const todayFormatted = getTodayFormatted();
|
) {
|
||||||
const isToday = String(payload.value) === todayFormatted;
|
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 (
|
return (
|
||||||
<g transform={`translate(${x},${y})`}>
|
<g transform={`translate(${x},${y})`}>
|
||||||
@@ -33,12 +61,23 @@ export const CustomXAxisTickWithToday = Object.assign(
|
|||||||
fill="var(--color-text-neutral-secondary)"
|
fill="var(--color-text-neutral-secondary)"
|
||||||
fontSize={AXIS_FONT_SIZE}
|
fontSize={AXIS_FONT_SIZE}
|
||||||
>
|
>
|
||||||
{payload.value}
|
{dayNumber}
|
||||||
</text>
|
</text>
|
||||||
|
{showMonth && (
|
||||||
|
<text
|
||||||
|
x={0}
|
||||||
|
y={42}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="var(--color-text-neutral-tertiary)"
|
||||||
|
fontSize={MONTH_FONT_SIZE}
|
||||||
|
>
|
||||||
|
{getMonthName(dateStr)}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
{isToday && (
|
{isToday && (
|
||||||
<text
|
<text
|
||||||
x={0}
|
x={0}
|
||||||
y={36}
|
y={showMonth ? 56 : 42}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
fill="var(--color-text-neutral-secondary)"
|
fill="var(--color-text-neutral-secondary)"
|
||||||
fontSize={TODAY_FONT_SIZE}
|
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 {
|
export interface LineDataPoint {
|
||||||
date: string;
|
date: string;
|
||||||
[key: string]: string | number;
|
[key: string]: string | number | string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RadarDataPoint {
|
export interface RadarDataPoint {
|
||||||
@@ -60,5 +60,5 @@ export interface TooltipData {
|
|||||||
new?: number;
|
new?: number;
|
||||||
muted?: number;
|
muted?: number;
|
||||||
change?: number;
|
change?: number;
|
||||||
[key: string]: any;
|
[key: string]: string | number | boolean | string[] | undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
getFindingsBySeverity,
|
getFindingsBySeverity,
|
||||||
getFindingsByStatus,
|
getFindingsByStatus,
|
||||||
getProvidersOverview,
|
getProvidersOverview,
|
||||||
} from "@/actions/overview/overview";
|
} from "@/actions/overview";
|
||||||
import {
|
import {
|
||||||
getFindingsBySeveritySchema,
|
getFindingsBySeveritySchema,
|
||||||
getFindingsByStatusSchema,
|
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 = (
|
export const extractProviderUIDs = (
|
||||||
providersData: ProvidersApiResponse,
|
providersData: ProvidersApiResponse,
|
||||||
): string[] => {
|
): string[] => {
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export const SEVERITY_COLORS: Record<SeverityLevel, string> = {
|
|||||||
informational: "var(--color-bg-data-info)",
|
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> = {
|
export const SEVERITY_FILTER_MAP: Record<string, SeverityLevel> = {
|
||||||
Critical: "critical",
|
Critical: "critical",
|
||||||
High: "high",
|
High: "high",
|
||||||
|
|||||||
Reference in New Issue
Block a user