mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-03-22 03:08:23 +00:00
Compare commits
35 Commits
dependabot
...
9f4063a6f1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f4063a6f1 | ||
|
|
d8a5127943 | ||
|
|
895fb80ea5 | ||
|
|
91fe7bc3c4 | ||
|
|
8822663e7f | ||
|
|
83950d53f0 | ||
|
|
dce87293d9 | ||
|
|
df3bd045b4 | ||
|
|
62e92e3f85 | ||
|
|
fb94b24006 | ||
|
|
88e89aa83a | ||
|
|
f884432007 | ||
|
|
31357d8186 | ||
|
|
3026bdc5d5 | ||
|
|
2cbb83fd93 | ||
|
|
d7e2cb3ffb | ||
|
|
aa8d2ca41d | ||
|
|
dcfa3db026 | ||
|
|
6019c7fb59 | ||
|
|
378a1562f9 | ||
|
|
54b5679868 | ||
|
|
2e28c7c54c | ||
|
|
6efd9d7169 | ||
|
|
5d31b5d5f9 | ||
|
|
eebb09503d | ||
|
|
776a5a443e | ||
|
|
8ec55b757f | ||
|
|
89a717c2cb | ||
|
|
a977e03176 | ||
|
|
05a1916f5a | ||
|
|
c296e2fd05 | ||
|
|
9a85906db5 | ||
|
|
f0d1ec8edb | ||
|
|
fec30d9f4e | ||
|
|
c6c5bc655f |
128
ui/actions/finding-groups/finding-groups.adapter.ts
Normal file
128
ui/actions/finding-groups/finding-groups.adapter.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
FindingGroupRow,
|
||||
FindingResourceRow,
|
||||
FINDINGS_ROW_TYPE,
|
||||
FindingStatus,
|
||||
ProviderType,
|
||||
Severity,
|
||||
} from "@/types";
|
||||
|
||||
/**
|
||||
* API response shape for a finding group (JSON:API).
|
||||
* Each group represents a unique check_id with aggregated counts.
|
||||
*
|
||||
* Fields come from FindingGroupSerializer which aggregates
|
||||
* FindingGroupDailySummary rows by check_id.
|
||||
*/
|
||||
interface FindingGroupApiItem {
|
||||
type: "finding-groups";
|
||||
id: string;
|
||||
attributes: {
|
||||
check_id: string;
|
||||
check_title: string | null;
|
||||
check_description: string | null;
|
||||
severity: string;
|
||||
status: string; // "FAIL" | "PASS" | "MUTED" (already uppercase)
|
||||
impacted_providers: string[];
|
||||
resources_total: number;
|
||||
resources_fail: number;
|
||||
pass_count: number;
|
||||
fail_count: number;
|
||||
muted_count: number;
|
||||
new_count: number;
|
||||
changed_count: number;
|
||||
first_seen_at: string | null;
|
||||
last_seen_at: string | null;
|
||||
failing_since: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the API response for finding groups into FindingGroupRow[].
|
||||
*/
|
||||
export function adaptFindingGroupsResponse(
|
||||
apiResponse: any,
|
||||
): FindingGroupRow[] {
|
||||
if (!apiResponse?.data || !Array.isArray(apiResponse.data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return apiResponse.data.map((item: FindingGroupApiItem) => ({
|
||||
id: item.id,
|
||||
rowType: FINDINGS_ROW_TYPE.GROUP,
|
||||
checkId: item.attributes.check_id,
|
||||
checkTitle: item.attributes.check_title || item.attributes.check_id,
|
||||
severity: item.attributes.severity as Severity,
|
||||
status: item.attributes.status as FindingStatus,
|
||||
resourcesTotal: item.attributes.resources_total,
|
||||
resourcesFail: item.attributes.resources_fail,
|
||||
newCount: item.attributes.new_count,
|
||||
changedCount: item.attributes.changed_count,
|
||||
mutedCount: item.attributes.muted_count,
|
||||
providers: (item.attributes.impacted_providers || []) as ProviderType[],
|
||||
updatedAt: item.attributes.last_seen_at || "",
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* API response shape for a finding group resource (drill-down).
|
||||
* Endpoint: /finding-groups/{check_id}/resources
|
||||
*
|
||||
* Each item has nested `resource` and `provider` objects in attributes
|
||||
* (NOT JSON:API included — it's a custom serializer).
|
||||
*/
|
||||
interface FindingGroupResourceApiItem {
|
||||
type: "finding-group-resources";
|
||||
id: string;
|
||||
attributes: {
|
||||
resource: {
|
||||
uid: string;
|
||||
name: string;
|
||||
service: string;
|
||||
region: string;
|
||||
type: string;
|
||||
};
|
||||
provider: {
|
||||
type: string;
|
||||
uid: string;
|
||||
alias: string;
|
||||
};
|
||||
status: string;
|
||||
severity: string;
|
||||
first_seen_at: string | null;
|
||||
last_seen_at: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the API response for finding group resources (drill-down)
|
||||
* into FindingResourceRow[].
|
||||
*/
|
||||
export function adaptFindingGroupResourcesResponse(
|
||||
apiResponse: any,
|
||||
checkId: string,
|
||||
): FindingResourceRow[] {
|
||||
if (!apiResponse?.data || !Array.isArray(apiResponse.data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return apiResponse.data.map((item: FindingGroupResourceApiItem) => ({
|
||||
id: item.id,
|
||||
rowType: FINDINGS_ROW_TYPE.RESOURCE,
|
||||
findingId: item.id,
|
||||
checkId,
|
||||
providerType: (item.attributes.provider?.type || "aws") as ProviderType,
|
||||
providerAlias: item.attributes.provider?.alias || "",
|
||||
providerUid: item.attributes.provider?.uid || "",
|
||||
resourceName: item.attributes.resource?.name || "-",
|
||||
resourceUid: item.attributes.resource?.uid || "-",
|
||||
service: item.attributes.resource?.service || "-",
|
||||
region: item.attributes.resource?.region || "-",
|
||||
severity: (item.attributes.severity || "informational") as Severity,
|
||||
status: item.attributes.status,
|
||||
isMuted: item.attributes.status === "MUTED",
|
||||
mutedReason: undefined,
|
||||
firstSeenAt: item.attributes.first_seen_at,
|
||||
lastSeenAt: item.attributes.last_seen_at,
|
||||
}));
|
||||
}
|
||||
144
ui/actions/finding-groups/finding-groups.ts
Normal file
144
ui/actions/finding-groups/finding-groups.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { appendSanitizedProviderFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
/**
|
||||
* Maps filter[search] to filter[check_title__icontains] for finding-groups.
|
||||
* The finding-groups endpoint supports check_title__icontains for substring
|
||||
* matching on the human-readable check title displayed in the table.
|
||||
*/
|
||||
function mapSearchFilter(
|
||||
filters: Record<string, string | string[] | undefined>,
|
||||
): Record<string, string | string[] | undefined> {
|
||||
const mapped = { ...filters };
|
||||
const searchValue = mapped["filter[search]"];
|
||||
if (searchValue) {
|
||||
mapped["filter[check_title__icontains]"] = searchValue;
|
||||
delete mapped["filter[search]"];
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
|
||||
export const getFindingGroups = async ({
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
sort = "",
|
||||
filters = {},
|
||||
}) => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
if (isNaN(Number(page)) || page < 1) redirect("/findings");
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/finding-groups`);
|
||||
|
||||
if (page) url.searchParams.append("page[number]", page.toString());
|
||||
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
|
||||
if (sort) url.searchParams.append("sort", sort);
|
||||
|
||||
appendSanitizedProviderFilters(url, mapSearchFilter(filters));
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { headers });
|
||||
return handleApiResponse(response);
|
||||
} catch (error) {
|
||||
console.error("Error fetching finding groups:", error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const getLatestFindingGroups = async ({
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
sort = "",
|
||||
filters = {},
|
||||
}) => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
if (isNaN(Number(page)) || page < 1) redirect("/findings");
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/finding-groups/latest`);
|
||||
|
||||
if (page) url.searchParams.append("page[number]", page.toString());
|
||||
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
|
||||
if (sort) url.searchParams.append("sort", sort);
|
||||
|
||||
appendSanitizedProviderFilters(url, mapSearchFilter(filters));
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { headers });
|
||||
return handleApiResponse(response);
|
||||
} catch (error) {
|
||||
console.error("Error fetching latest finding groups:", error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const getFindingGroupResources = async ({
|
||||
checkId,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
filters = {},
|
||||
}: {
|
||||
checkId: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
filters?: Record<string, string | string[] | undefined>;
|
||||
}) => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/finding-groups/${checkId}/resources`);
|
||||
|
||||
if (page) url.searchParams.append("page[number]", page.toString());
|
||||
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
|
||||
|
||||
appendSanitizedProviderFilters(url, filters);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
headers,
|
||||
});
|
||||
|
||||
return handleApiResponse(response);
|
||||
} catch (error) {
|
||||
console.error("Error fetching finding group resources:", error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const getLatestFindingGroupResources = async ({
|
||||
checkId,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
filters = {},
|
||||
}: {
|
||||
checkId: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
filters?: Record<string, string | string[] | undefined>;
|
||||
}) => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(
|
||||
`${apiBaseUrl}/finding-groups/latest/${checkId}/resources`,
|
||||
);
|
||||
|
||||
if (page) url.searchParams.append("page[number]", page.toString());
|
||||
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
|
||||
|
||||
appendSanitizedProviderFilters(url, filters);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
headers,
|
||||
});
|
||||
|
||||
return handleApiResponse(response);
|
||||
} catch (error) {
|
||||
console.error("Error fetching latest finding group resources:", error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
2
ui/actions/finding-groups/index.ts
Normal file
2
ui/actions/finding-groups/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./finding-groups";
|
||||
export * from "./finding-groups.adapter";
|
||||
168
ui/actions/findings/findings-by-resource.adapter.ts
Normal file
168
ui/actions/findings/findings-by-resource.adapter.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { createDict } from "@/lib";
|
||||
import { ProviderType, Severity } from "@/types";
|
||||
|
||||
/**
|
||||
* Flattened finding for the resource detail drawer.
|
||||
* Merges data from the finding attributes, its check_metadata,
|
||||
* the included resource, and the included scan/provider.
|
||||
*/
|
||||
export interface ResourceDrawerFinding {
|
||||
id: string;
|
||||
uid: string;
|
||||
checkId: string;
|
||||
checkTitle: string;
|
||||
status: string;
|
||||
severity: Severity;
|
||||
delta: string | null;
|
||||
isMuted: boolean;
|
||||
mutedReason: string | null;
|
||||
firstSeenAt: string | null;
|
||||
updatedAt: string | null;
|
||||
// Resource
|
||||
resourceUid: string;
|
||||
resourceName: string;
|
||||
resourceService: string;
|
||||
resourceRegion: string;
|
||||
resourceType: string;
|
||||
// Provider
|
||||
providerType: ProviderType;
|
||||
providerAlias: string;
|
||||
providerUid: string;
|
||||
// Check metadata (flattened)
|
||||
risk: string;
|
||||
description: string;
|
||||
statusExtended: string;
|
||||
complianceFrameworks: string[];
|
||||
categories: string[];
|
||||
remediation: {
|
||||
recommendation: { text: string; url: string };
|
||||
code: {
|
||||
cli: string;
|
||||
other: string;
|
||||
nativeiac: string;
|
||||
terraform: string;
|
||||
};
|
||||
};
|
||||
additionalUrls: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts unique compliance framework names from available data.
|
||||
*
|
||||
* Supports two shapes:
|
||||
* 1. check_metadata.compliance — array of { Framework, Version, ... } objects
|
||||
* e.g. [{ Framework: "CIS-AWS", Version: "1.4" }, { Framework: "PCI-DSS" }]
|
||||
* 2. finding.compliance — dict with versioned keys (when API exposes it)
|
||||
* e.g. {"CIS-AWS-1.4": ["2.1"], "PCI-DSS-3.2": ["6.2"]}
|
||||
*/
|
||||
function extractComplianceFrameworks(
|
||||
metaCompliance: unknown,
|
||||
findingCompliance: Record<string, string[]> | null | undefined,
|
||||
): string[] {
|
||||
const frameworks = new Set<string>();
|
||||
|
||||
// Source 1: check_metadata.compliance — array of objects with Framework field
|
||||
if (Array.isArray(metaCompliance)) {
|
||||
for (const entry of metaCompliance) {
|
||||
if (entry?.Framework || entry?.framework) {
|
||||
frameworks.add(entry.Framework || entry.framework);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Source 2: finding.compliance — dict keys like "CIS-AWS-1.4"
|
||||
if (findingCompliance && typeof findingCompliance === "object") {
|
||||
for (const key of Object.keys(findingCompliance)) {
|
||||
const base = key.replace(/-\d+(\.\d+)*$/, "");
|
||||
frameworks.add(base);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(frameworks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the `/findings/latest?include=resources,scan.provider` response
|
||||
* into a flat ResourceDrawerFinding array.
|
||||
*
|
||||
* Uses createDict to build lookup maps from the JSON:API `included` array,
|
||||
* then resolves each finding's resource and provider relationships.
|
||||
*/
|
||||
export function adaptFindingsByResourceResponse(
|
||||
apiResponse: any,
|
||||
): ResourceDrawerFinding[] {
|
||||
if (!apiResponse?.data || !Array.isArray(apiResponse.data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const resourcesDict = createDict("resources", apiResponse);
|
||||
const scansDict = createDict("scans", apiResponse);
|
||||
const providersDict = createDict("providers", apiResponse);
|
||||
|
||||
return apiResponse.data.map((item: any) => {
|
||||
const attrs = item.attributes;
|
||||
const meta = attrs.check_metadata || {};
|
||||
const remediation = meta.remediation || {
|
||||
recommendation: { text: "", url: "" },
|
||||
code: { cli: "", other: "", nativeiac: "", terraform: "" },
|
||||
};
|
||||
|
||||
// Resolve resource from included
|
||||
const resourceRel = item.relationships?.resources?.data?.[0];
|
||||
const resource = resourceRel ? resourcesDict[resourceRel.id] : null;
|
||||
const resourceAttrs = resource?.attributes || {};
|
||||
|
||||
// Resolve provider via scan → provider (include path: scan.provider)
|
||||
const scanRel = item.relationships?.scan?.data;
|
||||
const scan = scanRel ? scansDict[scanRel.id] : null;
|
||||
const providerRelId = scan?.relationships?.provider?.data?.id ?? null;
|
||||
const provider = providerRelId ? providersDict[providerRelId] : null;
|
||||
const providerAttrs = provider?.attributes || {};
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
uid: attrs.uid,
|
||||
checkId: attrs.check_id,
|
||||
checkTitle: meta.checktitle || attrs.check_id,
|
||||
status: attrs.status,
|
||||
severity: (attrs.severity || "informational") as Severity,
|
||||
delta: attrs.delta || null,
|
||||
isMuted: Boolean(attrs.muted),
|
||||
mutedReason: attrs.muted_reason || null,
|
||||
firstSeenAt: attrs.first_seen_at || null,
|
||||
updatedAt: attrs.updated_at || null,
|
||||
// Resource
|
||||
resourceUid: resourceAttrs.uid || "-",
|
||||
resourceName: resourceAttrs.name || "-",
|
||||
resourceService: resourceAttrs.service || "-",
|
||||
resourceRegion: resourceAttrs.region || "-",
|
||||
resourceType: resourceAttrs.type || "-",
|
||||
// Provider
|
||||
providerType: (providerAttrs.provider || "aws") as ProviderType,
|
||||
providerAlias: providerAttrs.alias || "",
|
||||
providerUid: providerAttrs.uid || "",
|
||||
// Check metadata
|
||||
risk: meta.risk || "",
|
||||
description: meta.description || "",
|
||||
statusExtended: attrs.status_extended || "",
|
||||
complianceFrameworks: extractComplianceFrameworks(
|
||||
meta.compliance ?? meta.Compliance,
|
||||
attrs.compliance,
|
||||
),
|
||||
categories: meta.categories || [],
|
||||
remediation: {
|
||||
recommendation: {
|
||||
text: remediation.recommendation?.text || "",
|
||||
url: remediation.recommendation?.url || "",
|
||||
},
|
||||
code: {
|
||||
cli: remediation.code?.cli || "",
|
||||
other: remediation.code?.other || "",
|
||||
nativeiac: remediation.code?.nativeiac || "",
|
||||
terraform: remediation.code?.terraform || "",
|
||||
},
|
||||
},
|
||||
additionalUrls: meta.additionalurls || [],
|
||||
};
|
||||
});
|
||||
}
|
||||
95
ui/actions/findings/findings-by-resource.ts
Normal file
95
ui/actions/findings/findings-by-resource.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
/**
|
||||
* Resolves resource UIDs + check ID into actual finding UUIDs.
|
||||
* Uses /findings/latest with check_id and resource_uid__in filters
|
||||
* to batch-resolve in a single API call.
|
||||
*/
|
||||
export const resolveFindingIds = async ({
|
||||
checkId,
|
||||
resourceUids,
|
||||
}: {
|
||||
checkId: string;
|
||||
resourceUids: string[];
|
||||
}): Promise<string[]> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/findings/latest`);
|
||||
url.searchParams.append("filter[check_id]", checkId);
|
||||
url.searchParams.append("filter[resource_uid__in]", resourceUids.join(","));
|
||||
url.searchParams.append("page[size]", resourceUids.length.toString());
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { headers });
|
||||
const data = await handleApiResponse(response);
|
||||
|
||||
if (!data?.data || !Array.isArray(data.data)) return [];
|
||||
|
||||
return data.data.map((item: { id: string }) => item.id);
|
||||
} catch (error) {
|
||||
console.error("Error resolving finding IDs:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves check IDs into actual finding UUIDs.
|
||||
* Used at the group level where each row represents a check_id.
|
||||
*/
|
||||
export const resolveFindingIdsByCheckIds = async ({
|
||||
checkIds,
|
||||
}: {
|
||||
checkIds: string[];
|
||||
}): Promise<string[]> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/findings/latest`);
|
||||
url.searchParams.append("filter[check_id__in]", checkIds.join(","));
|
||||
url.searchParams.append("page[size]", "500");
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { headers });
|
||||
const data = await handleApiResponse(response);
|
||||
|
||||
if (!data?.data || !Array.isArray(data.data)) return [];
|
||||
|
||||
return data.data.map((item: { id: string }) => item.id);
|
||||
} catch (error) {
|
||||
console.error("Error resolving finding IDs by check IDs:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const getLatestFindingsByResourceUid = async ({
|
||||
resourceUid,
|
||||
page = 1,
|
||||
pageSize = 50,
|
||||
}: {
|
||||
resourceUid: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(
|
||||
`${apiBaseUrl}/findings/latest?include=resources,scan.provider`,
|
||||
);
|
||||
|
||||
url.searchParams.append("filter[resource_uid]", resourceUid);
|
||||
if (page) url.searchParams.append("page[number]", page.toString());
|
||||
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
|
||||
|
||||
try {
|
||||
const findings = await fetch(url.toString(), {
|
||||
headers,
|
||||
});
|
||||
|
||||
return handleApiResponse(findings);
|
||||
} catch (error) {
|
||||
console.error("Error fetching findings by resource UID:", error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
@@ -1 +1,3 @@
|
||||
export * from "./findings";
|
||||
export * from "./findings-by-resource";
|
||||
export * from "./findings-by-resource.adapter";
|
||||
|
||||
@@ -188,7 +188,11 @@ export const ProviderTypeSelector = ({
|
||||
// .filter((p) => p.attributes.connection?.connected)
|
||||
.map((p) => p.attributes.provider),
|
||||
),
|
||||
).filter((type): type is ProviderType => type in PROVIDER_DATA);
|
||||
)
|
||||
.filter((type): type is ProviderType => type in PROVIDER_DATA)
|
||||
.sort((a, b) =>
|
||||
PROVIDER_DATA[a].label.localeCompare(PROVIDER_DATA[b].label),
|
||||
);
|
||||
|
||||
const renderIcon = (providerType: ProviderType) => {
|
||||
const IconComponent = PROVIDER_DATA[providerType].icon;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import {
|
||||
adaptFindingGroupsResponse,
|
||||
getFindingGroups,
|
||||
getLatestFindingGroups,
|
||||
} from "@/actions/finding-groups";
|
||||
import {
|
||||
getFindingById,
|
||||
getFindings,
|
||||
getLatestFindings,
|
||||
getLatestMetadataInfo,
|
||||
getMetadataInfo,
|
||||
} from "@/actions/findings";
|
||||
@@ -12,13 +15,12 @@ import { getScans } from "@/actions/scans";
|
||||
import { FindingDetailsSheet } from "@/components/findings";
|
||||
import { FindingsFilters } from "@/components/findings/findings-filters";
|
||||
import {
|
||||
FindingsTableWithSelection,
|
||||
FindingsGroupTable,
|
||||
SkeletonTableFindings,
|
||||
} from "@/components/findings/table";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { FilterTransitionWrapper } from "@/contexts";
|
||||
import {
|
||||
createDict,
|
||||
createScanDetailsMapping,
|
||||
extractFiltersAndQuery,
|
||||
extractSortAndKey,
|
||||
@@ -60,13 +62,12 @@ export default async function Findings({
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
// Process the finding data to match the expected structure
|
||||
// Process the finding data to match the expected structure (for detail sheet)
|
||||
const processedFinding = findingByIdData?.data
|
||||
? (() => {
|
||||
const finding = findingByIdData.data;
|
||||
const included = findingByIdData.included || [];
|
||||
|
||||
// Build dictionaries from included data
|
||||
type IncludedItem = {
|
||||
type: string;
|
||||
id: string;
|
||||
@@ -179,65 +180,40 @@ const SSRDataTable = async ({
|
||||
}) => {
|
||||
const page = parseInt(searchParams.page?.toString() || "1", 10);
|
||||
const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10);
|
||||
const defaultSort = "severity,status,-inserted_at";
|
||||
const defaultSort = "-severity,-fail_count,-last_seen_at";
|
||||
|
||||
const { encodedSort } = extractSortAndKey({
|
||||
...searchParams,
|
||||
sort: searchParams.sort ?? defaultSort,
|
||||
});
|
||||
|
||||
const { filters, query } = extractFiltersAndQuery(searchParams);
|
||||
const { filters } = extractFiltersAndQuery(searchParams);
|
||||
// Check if the searchParams contain any date or scan filter
|
||||
const hasDateOrScan = hasDateOrScanFilter(searchParams);
|
||||
|
||||
const fetchFindings = hasDateOrScan ? getFindings : getLatestFindings;
|
||||
const fetchFindingGroups = hasDateOrScan
|
||||
? getFindingGroups
|
||||
: getLatestFindingGroups;
|
||||
|
||||
const findingsData = await fetchFindings({
|
||||
query,
|
||||
const findingGroupsData = await fetchFindingGroups({
|
||||
page,
|
||||
sort: encodedSort,
|
||||
filters,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
// Create dictionaries for resources, scans, and providers
|
||||
const resourceDict = createDict("resources", findingsData);
|
||||
const scanDict = createDict("scans", findingsData);
|
||||
const providerDict = createDict("providers", findingsData);
|
||||
|
||||
// Expand each finding with its corresponding resource, scan, and provider
|
||||
const expandedFindings = findingsData?.data
|
||||
? findingsData.data.map((finding: FindingProps) => {
|
||||
const scan = scanDict[finding.relationships?.scan?.data?.id];
|
||||
const resource =
|
||||
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
|
||||
const provider = providerDict[scan?.relationships?.provider?.data?.id];
|
||||
|
||||
return {
|
||||
...finding,
|
||||
relationships: { scan, resource, provider },
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
// Create the new object while maintaining the original structure
|
||||
const expandedResponse = {
|
||||
...findingsData,
|
||||
data: expandedFindings,
|
||||
};
|
||||
// Transform API response to FindingGroupRow[]
|
||||
const groups = adaptFindingGroupsResponse(findingGroupsData);
|
||||
|
||||
return (
|
||||
<>
|
||||
{findingsData?.errors && (
|
||||
{findingGroupsData?.errors && (
|
||||
<div className="text-small mb-4 flex rounded-lg border border-red-500 bg-red-100 p-2 text-red-700">
|
||||
<p className="mr-2 font-semibold">Error:</p>
|
||||
<p>{findingsData.errors[0].detail}</p>
|
||||
<p>{findingGroupsData.errors[0].detail}</p>
|
||||
</div>
|
||||
)}
|
||||
<FindingsTableWithSelection
|
||||
data={expandedResponse?.data || []}
|
||||
metadata={findingsData?.meta}
|
||||
/>
|
||||
<FindingsGroupTable data={groups} metadata={findingGroupsData?.meta} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,7 +10,15 @@ import { ContentLayout } from "@/components/ui";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AIChatbot() {
|
||||
export default async function AIChatbot({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const initialPrompt =
|
||||
typeof params.prompt === "string" ? params.prompt : undefined;
|
||||
|
||||
const hasConfig = await isLighthouseConfigured();
|
||||
|
||||
if (!hasConfig) {
|
||||
@@ -33,6 +41,7 @@ export default async function AIChatbot() {
|
||||
providers={providersConfig.providers}
|
||||
defaultProviderId={providersConfig.defaultProviderId}
|
||||
defaultModelId={providersConfig.defaultModelId}
|
||||
initialPrompt={initialPrompt}
|
||||
/>
|
||||
</div>
|
||||
</ContentLayout>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Tooltip } from "@heroui/tooltip";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
|
||||
import { MutedIcon } from "../icons";
|
||||
|
||||
@@ -14,10 +18,16 @@ export const Muted = ({
|
||||
if (isMuted === false) return null;
|
||||
|
||||
return (
|
||||
<Tooltip content={mutedReason} className="text-xs">
|
||||
<div className="border-system-severity-critical/40 w-fit rounded-full border p-1">
|
||||
<MutedIcon className="text-system-severity-critical h-4 w-4" />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1">
|
||||
<MutedIcon className="text-text-neutral-primary size-2" />
|
||||
<span className="text-text-neutral-primary text-sm">Muted</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span className="text-xs">{mutedReason}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
185
ui/components/findings/table/column-finding-groups.tsx
Normal file
185
ui/components/findings/table/column-finding-groups.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef, RowSelectionState } from "@tanstack/react-table";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
import { Checkbox } from "@/components/shadcn";
|
||||
import {
|
||||
DataTableColumnHeader,
|
||||
SeverityBadge,
|
||||
StatusFindingBadge,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib";
|
||||
import { FindingGroupRow, ProviderType } from "@/types";
|
||||
|
||||
import { DataTableRowActions } from "./data-table-row-actions";
|
||||
import { ImpactedProvidersCell } from "./impacted-providers-cell";
|
||||
import { ImpactedResourcesCell } from "./impacted-resources-cell";
|
||||
import { DeltaValues, NotificationIndicator } from "./notification-indicator";
|
||||
|
||||
interface GetColumnFindingGroupsOptions {
|
||||
rowSelection: RowSelectionState;
|
||||
selectableRowCount: number;
|
||||
onDrillDown: (checkId: string, group: FindingGroupRow) => void;
|
||||
}
|
||||
|
||||
export function getColumnFindingGroups({
|
||||
rowSelection,
|
||||
selectableRowCount,
|
||||
onDrillDown,
|
||||
}: GetColumnFindingGroupsOptions): ColumnDef<FindingGroupRow>[] {
|
||||
const selectedCount = Object.values(rowSelection).filter(Boolean).length;
|
||||
const isAllSelected =
|
||||
selectedCount > 0 && selectedCount === selectableRowCount;
|
||||
const isSomeSelected =
|
||||
selectedCount > 0 && selectedCount < selectableRowCount;
|
||||
|
||||
return [
|
||||
// Combined column: notification + expand toggle + checkbox
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => {
|
||||
const headerChecked = isAllSelected
|
||||
? true
|
||||
: isSomeSelected
|
||||
? "indeterminate"
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2" />
|
||||
<div className="w-4" />
|
||||
<Checkbox
|
||||
size="sm"
|
||||
checked={headerChecked}
|
||||
onCheckedChange={(checked) =>
|
||||
table.toggleAllPageRowsSelected(checked === true)
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Select all"
|
||||
disabled={selectableRowCount === 0}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const group = row.original;
|
||||
|
||||
const delta =
|
||||
group.newCount > 0
|
||||
? DeltaValues.NEW
|
||||
: group.changedCount > 0
|
||||
? DeltaValues.CHANGED
|
||||
: DeltaValues.NONE;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationIndicator delta={delta} />
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Expand ${group.checkTitle}`}
|
||||
className="hover:bg-bg-neutral-tertiary flex size-4 shrink-0 items-center justify-center rounded-md transition-colors"
|
||||
onClick={() => onDrillDown(group.checkId, group)}
|
||||
>
|
||||
<ChevronRight className="text-text-neutral-secondary size-4" />
|
||||
</button>
|
||||
<Checkbox
|
||||
size="sm"
|
||||
checked={!!rowSelection[row.id]}
|
||||
onCheckedChange={(checked) =>
|
||||
row.toggleSelected(checked === true)
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
// Status column — not sortable on finding-groups endpoint
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Status" />
|
||||
),
|
||||
cell: ({ row }) => <StatusFindingBadge status={row.original.status} />,
|
||||
enableSorting: false,
|
||||
},
|
||||
// Finding title column
|
||||
{
|
||||
accessorKey: "finding",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title="Finding"
|
||||
param="check_id"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const group = row.original;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p
|
||||
className="text-text-neutral-primary hover:text-button-tertiary cursor-pointer text-left text-sm break-words whitespace-normal hover:underline"
|
||||
onClick={() => onDrillDown(group.checkId, group)}
|
||||
>
|
||||
{group.checkTitle}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
// Severity column
|
||||
{
|
||||
accessorKey: "severity",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title="Severity"
|
||||
param="severity"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => <SeverityBadge severity={row.original.severity} />,
|
||||
},
|
||||
// Impacted Providers column
|
||||
{
|
||||
id: "impactedProviders",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Impacted Providers" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<ImpactedProvidersCell
|
||||
providers={row.original.providers as ProviderType[]}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
// Impacted Resources column
|
||||
{
|
||||
id: "impactedResources",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Impacted Resources" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const group = row.original;
|
||||
return (
|
||||
<ImpactedResourcesCell
|
||||
impacted={group.resourcesFail}
|
||||
total={group.resourcesTotal}
|
||||
/>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
// Actions column
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <div className="w-10" />,
|
||||
cell: ({ row }) => <DataTableRowActions row={row} />,
|
||||
enableSorting: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
317
ui/components/findings/table/column-finding-resources.tsx
Normal file
317
ui/components/findings/table/column-finding-resources.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef, Row, RowSelectionState } from "@tanstack/react-table";
|
||||
import { CornerDownRight, VolumeOff, VolumeX } from "lucide-react";
|
||||
import { useContext, useState } from "react";
|
||||
|
||||
import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
|
||||
import { VerticalDotsIcon } from "@/components/icons";
|
||||
import { Checkbox } from "@/components/shadcn";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { DateWithTime } from "@/components/ui/entities";
|
||||
import { EntityInfo } from "@/components/ui/entities/entity-info";
|
||||
import { SeverityBadge } from "@/components/ui/table";
|
||||
import { FindingResourceRow } from "@/types";
|
||||
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
import { NotificationIndicator } from "./notification-indicator";
|
||||
|
||||
/**
|
||||
* Computes a human-readable "failing for" duration from first_seen_at to now.
|
||||
* Returns null if the resource is not failing or has no first_seen_at.
|
||||
*/
|
||||
function getFailingForLabel(firstSeenAt: string | null): string | null {
|
||||
if (!firstSeenAt) return null;
|
||||
|
||||
const start = new Date(firstSeenAt);
|
||||
if (isNaN(start.getTime())) return null;
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - start.getTime();
|
||||
if (diffMs < 0) return null;
|
||||
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 1) return "< 1 day";
|
||||
if (diffDays < 30) return `${diffDays} day${diffDays > 1 ? "s" : ""}`;
|
||||
|
||||
const diffMonths = Math.floor(diffDays / 30);
|
||||
if (diffMonths < 12) return `${diffMonths} month${diffMonths > 1 ? "s" : ""}`;
|
||||
|
||||
const diffYears = Math.floor(diffMonths / 12);
|
||||
return `${diffYears} year${diffYears > 1 ? "s" : ""}`;
|
||||
}
|
||||
|
||||
const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
|
||||
const resource = row.original;
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
const [resolvedIds, setResolvedIds] = useState<string[]>([]);
|
||||
const [isResolving, setIsResolving] = useState(false);
|
||||
|
||||
const { selectedFindingIds, clearSelection, resolveMuteIds } = useContext(
|
||||
FindingsSelectionContext,
|
||||
) || {
|
||||
selectedFindingIds: [],
|
||||
clearSelection: () => {},
|
||||
};
|
||||
|
||||
const isCurrentSelected = selectedFindingIds.includes(resource.findingId);
|
||||
const hasMultipleSelected = selectedFindingIds.length > 1;
|
||||
|
||||
const getDisplayIds = (): string[] => {
|
||||
if (isCurrentSelected && hasMultipleSelected) {
|
||||
return selectedFindingIds;
|
||||
}
|
||||
return [resource.findingId];
|
||||
};
|
||||
|
||||
const getMuteLabel = () => {
|
||||
if (resource.isMuted) return "Muted";
|
||||
const ids = getDisplayIds();
|
||||
if (ids.length > 1) return `Mute ${ids.length}`;
|
||||
return "Mute";
|
||||
};
|
||||
|
||||
const handleMuteClick = async () => {
|
||||
const displayIds = getDisplayIds();
|
||||
|
||||
if (resolveMuteIds) {
|
||||
setIsResolving(true);
|
||||
const ids = await resolveMuteIds(displayIds);
|
||||
setResolvedIds(ids);
|
||||
setIsResolving(false);
|
||||
if (ids.length > 0) setIsMuteModalOpen(true);
|
||||
} else {
|
||||
setResolvedIds(displayIds);
|
||||
setIsMuteModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMuteComplete = () => {
|
||||
clearSelection();
|
||||
setResolvedIds([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MuteFindingsModal
|
||||
isOpen={isMuteModalOpen}
|
||||
onOpenChange={setIsMuteModalOpen}
|
||||
findingIds={resolvedIds}
|
||||
onComplete={handleMuteComplete}
|
||||
/>
|
||||
<div
|
||||
className="flex items-center justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ActionDropdown
|
||||
trigger={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Resource actions"
|
||||
className="hover:bg-bg-neutral-tertiary rounded-md p-1 transition-colors"
|
||||
>
|
||||
<VerticalDotsIcon
|
||||
size={20}
|
||||
className="text-text-neutral-secondary"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
ariaLabel="Resource actions"
|
||||
>
|
||||
<ActionDropdownItem
|
||||
icon={
|
||||
resource.isMuted ? (
|
||||
<VolumeOff className="size-5" />
|
||||
) : isResolving ? (
|
||||
<div className="size-5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
<VolumeX className="size-5" />
|
||||
)
|
||||
}
|
||||
label={isResolving ? "Resolving..." : getMuteLabel()}
|
||||
disabled={resource.isMuted || isResolving}
|
||||
onSelect={handleMuteClick}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface GetColumnFindingResourcesOptions {
|
||||
rowSelection: RowSelectionState;
|
||||
selectableRowCount: number;
|
||||
}
|
||||
|
||||
export function getColumnFindingResources({
|
||||
rowSelection,
|
||||
selectableRowCount,
|
||||
}: GetColumnFindingResourcesOptions): ColumnDef<FindingResourceRow>[] {
|
||||
const selectedCount = Object.values(rowSelection).filter(Boolean).length;
|
||||
const isAllSelected =
|
||||
selectedCount > 0 && selectedCount === selectableRowCount;
|
||||
const isSomeSelected =
|
||||
selectedCount > 0 && selectedCount < selectableRowCount;
|
||||
|
||||
return [
|
||||
// Combined column: notification + child icon + checkbox
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => {
|
||||
const headerChecked = isAllSelected
|
||||
? true
|
||||
: isSomeSelected
|
||||
? "indeterminate"
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2" />
|
||||
<div className="w-4" />
|
||||
<Checkbox
|
||||
size="sm"
|
||||
checked={headerChecked}
|
||||
onCheckedChange={(checked) =>
|
||||
table.toggleAllPageRowsSelected(checked === true)
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Select all resources"
|
||||
disabled={selectableRowCount === 0}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationIndicator
|
||||
isMuted={row.original.isMuted}
|
||||
mutedReason={row.original.mutedReason}
|
||||
/>
|
||||
<CornerDownRight className="text-text-neutral-tertiary h-4 w-4 shrink-0" />
|
||||
<Checkbox
|
||||
size="sm"
|
||||
checked={!!rowSelection[row.id]}
|
||||
disabled={row.original.isMuted}
|
||||
onCheckedChange={(checked) => row.toggleSelected(checked === true)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Select resource"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
// Resource — name + uid (EntityInfo with resource icon)
|
||||
{
|
||||
id: "resource",
|
||||
header: () => (
|
||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
||||
Resource
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<EntityInfo
|
||||
entityAlias={row.original.resourceName}
|
||||
entityId={row.original.resourceUid}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
// Service
|
||||
{
|
||||
id: "service",
|
||||
header: () => (
|
||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
||||
Service
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<p className="text-text-neutral-primary max-w-[100px] truncate text-sm">
|
||||
{row.original.service}
|
||||
</p>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
// Region
|
||||
{
|
||||
id: "region",
|
||||
header: () => (
|
||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
||||
Region
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<p className="text-text-neutral-primary max-w-[120px] truncate text-sm">
|
||||
{row.original.region}
|
||||
</p>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
// Severity
|
||||
{
|
||||
id: "severity",
|
||||
header: () => (
|
||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
||||
Severity
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => <SeverityBadge severity={row.original.severity} />,
|
||||
enableSorting: false,
|
||||
},
|
||||
// Account — alias + uid (EntityInfo with provider logo)
|
||||
{
|
||||
id: "account",
|
||||
header: () => (
|
||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
||||
Account
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<EntityInfo
|
||||
cloudProvider={row.original.providerType}
|
||||
entityAlias={row.original.providerAlias}
|
||||
entityId={row.original.providerUid}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
// Last seen
|
||||
{
|
||||
id: "lastSeen",
|
||||
header: () => (
|
||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
||||
Last seen
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => <DateWithTime dateTime={row.original.lastSeenAt} />,
|
||||
enableSorting: false,
|
||||
},
|
||||
// Failing for — duration since first_seen_at
|
||||
{
|
||||
id: "failingFor",
|
||||
header: () => (
|
||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
||||
Failing for
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const label = getFailingForLabel(row.original.firstSeenAt);
|
||||
return (
|
||||
<p className="text-text-neutral-primary text-sm">{label || "-"}</p>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
// Actions column — mute only
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <div className="w-10" />,
|
||||
cell: ({ row }) => <ResourceRowActions row={row} />,
|
||||
enableSorting: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -18,12 +18,36 @@ import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
|
||||
export interface FindingRowData {
|
||||
id: string;
|
||||
attributes: {
|
||||
attributes?: {
|
||||
muted?: boolean;
|
||||
check_metadata?: {
|
||||
checktitle?: string;
|
||||
};
|
||||
};
|
||||
// Flat shape for FindingGroupRow
|
||||
rowType?: string;
|
||||
checkTitle?: string;
|
||||
mutedCount?: number;
|
||||
resourcesTotal?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract muted state and title from either FindingProps (nested attributes)
|
||||
* or FindingGroupRow (flat shape with rowType discriminant).
|
||||
*/
|
||||
function extractRowInfo(data: FindingRowData) {
|
||||
if (data.rowType === "group") {
|
||||
const allMuted =
|
||||
(data.mutedCount ?? 0) > 0 && data.mutedCount === data.resourcesTotal;
|
||||
return {
|
||||
isMuted: allMuted,
|
||||
title: data.checkTitle || "Security Finding",
|
||||
};
|
||||
}
|
||||
return {
|
||||
isMuted: data.attributes?.muted ?? false,
|
||||
title: data.attributes?.check_metadata?.checktitle || "Security Finding",
|
||||
};
|
||||
}
|
||||
|
||||
interface DataTableRowActionsProps<T extends FindingRowData> {
|
||||
@@ -40,48 +64,61 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
|
||||
const isMuted = finding.attributes.muted;
|
||||
const { isMuted, title: findingTitle } = extractRowInfo(finding);
|
||||
|
||||
// Get selection context - if there are other selected rows, include them
|
||||
const selectionContext = useContext(FindingsSelectionContext);
|
||||
const { selectedFindingIds, clearSelection } = selectionContext || {
|
||||
selectedFindingIds: [],
|
||||
clearSelection: () => {},
|
||||
};
|
||||
const { selectedFindingIds, clearSelection, resolveMuteIds } =
|
||||
selectionContext || {
|
||||
selectedFindingIds: [],
|
||||
clearSelection: () => {},
|
||||
};
|
||||
|
||||
const findingTitle =
|
||||
finding.attributes.check_metadata?.checktitle || "Security Finding";
|
||||
const [resolvedIds, setResolvedIds] = useState<string[]>([]);
|
||||
const [isResolving, setIsResolving] = useState(false);
|
||||
|
||||
// If current finding is selected and there are multiple selections, mute all
|
||||
// Otherwise, just mute this single finding
|
||||
const isCurrentSelected = selectedFindingIds.includes(finding.id);
|
||||
const hasMultipleSelected = selectedFindingIds.length > 1;
|
||||
|
||||
const getMuteIds = (): string[] => {
|
||||
const getDisplayIds = (): string[] => {
|
||||
if (isCurrentSelected && hasMultipleSelected) {
|
||||
// Mute all selected including current
|
||||
return selectedFindingIds;
|
||||
}
|
||||
// Just mute the current finding
|
||||
return [finding.id];
|
||||
};
|
||||
|
||||
const getMuteLabel = () => {
|
||||
if (isMuted) return "Muted";
|
||||
const ids = getMuteIds();
|
||||
const ids = getDisplayIds();
|
||||
if (ids.length > 1) {
|
||||
return `Mute ${ids.length} Findings`;
|
||||
}
|
||||
return "Mute Finding";
|
||||
};
|
||||
|
||||
const handleMuteClick = async () => {
|
||||
const displayIds = getDisplayIds();
|
||||
|
||||
if (resolveMuteIds) {
|
||||
setIsResolving(true);
|
||||
const ids = await resolveMuteIds(displayIds);
|
||||
setResolvedIds(ids);
|
||||
setIsResolving(false);
|
||||
if (ids.length > 0) setIsMuteModalOpen(true);
|
||||
} else {
|
||||
// Regular findings — IDs are already valid finding UUIDs
|
||||
setResolvedIds(displayIds);
|
||||
setIsMuteModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMuteComplete = () => {
|
||||
// Always clear selection when a finding is muted because:
|
||||
// 1. If the muted finding was selected, its index now points to a different finding
|
||||
// 2. rowSelection uses indices (0, 1, 2...) not IDs, so after refresh the wrong findings would appear selected
|
||||
clearSelection();
|
||||
setResolvedIds([]);
|
||||
if (onMuteComplete) {
|
||||
onMuteComplete(getMuteIds());
|
||||
onMuteComplete(getDisplayIds());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -100,7 +137,7 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
<MuteFindingsModal
|
||||
isOpen={isMuteModalOpen}
|
||||
onOpenChange={setIsMuteModalOpen}
|
||||
findingIds={getMuteIds()}
|
||||
findingIds={resolvedIds}
|
||||
onComplete={handleMuteComplete}
|
||||
/>
|
||||
|
||||
@@ -124,15 +161,15 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
icon={
|
||||
isMuted ? (
|
||||
<VolumeOff className="size-5" />
|
||||
) : isResolving ? (
|
||||
<div className="size-5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
<VolumeX className="size-5" />
|
||||
)
|
||||
}
|
||||
label={getMuteLabel()}
|
||||
disabled={isMuted}
|
||||
onSelect={() => {
|
||||
setIsMuteModalOpen(true);
|
||||
}}
|
||||
label={isResolving ? "Resolving..." : getMuteLabel()}
|
||||
disabled={isMuted || isResolving}
|
||||
onSelect={handleMuteClick}
|
||||
/>
|
||||
<ActionDropdownItem
|
||||
icon={<JiraIcon size={20} />}
|
||||
|
||||
362
ui/components/findings/table/findings-group-drill-down.tsx
Normal file
362
ui/components/findings/table/findings-group-drill-down.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
Row,
|
||||
RowSelectionState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { ChevronLeft, VolumeX } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { resolveFindingIds } from "@/actions/findings/findings-by-resource";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table";
|
||||
import { useInfiniteResources } from "@/hooks/use-infinite-resources";
|
||||
import { cn, hasDateOrScanFilter } from "@/lib";
|
||||
import { FindingGroupRow, FindingResourceRow } from "@/types";
|
||||
|
||||
import { MuteFindingsModal } from "../mute-findings-modal";
|
||||
import { getColumnFindingResources } from "./column-finding-resources";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
import { ImpactedResourcesCell } from "./impacted-resources-cell";
|
||||
import { DeltaValues, NotificationIndicator } from "./notification-indicator";
|
||||
import {
|
||||
ResourceDetailDrawer,
|
||||
useResourceDetailDrawer,
|
||||
} from "./resource-detail-drawer";
|
||||
|
||||
interface FindingsGroupDrillDownProps {
|
||||
group: FindingGroupRow;
|
||||
onCollapse: () => void;
|
||||
}
|
||||
|
||||
export function FindingsGroupDrillDown({
|
||||
group,
|
||||
onCollapse,
|
||||
}: FindingsGroupDrillDownProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [resources, setResources] = useState<FindingResourceRow[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Derive hasDateOrScan from current URL params
|
||||
const currentParams = useMemo(
|
||||
() => Object.fromEntries(searchParams.entries()),
|
||||
[searchParams],
|
||||
);
|
||||
const hasDateOrScan = hasDateOrScanFilter(currentParams);
|
||||
|
||||
// Stabilize filters object — only recompute when searchParams change
|
||||
const filters = useMemo(() => {
|
||||
const result: Record<string, string> = {};
|
||||
searchParams.forEach((value, key) => {
|
||||
if (key.startsWith("filter[") || key.includes("__in")) {
|
||||
result[key] = value;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}, [searchParams]);
|
||||
|
||||
const handleSetResources = useCallback(
|
||||
(newResources: FindingResourceRow[], _hasMore: boolean) => {
|
||||
setResources(newResources);
|
||||
setIsLoading(false);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleAppendResources = useCallback(
|
||||
(newResources: FindingResourceRow[], _hasMore: boolean) => {
|
||||
setResources((prev) => [...prev, ...newResources]);
|
||||
setIsLoading(false);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSetLoading = useCallback((loading: boolean) => {
|
||||
setIsLoading(loading);
|
||||
}, []);
|
||||
|
||||
const { sentinelRef } = useInfiniteResources({
|
||||
checkId: group.checkId,
|
||||
hasDateOrScanFilter: hasDateOrScan,
|
||||
filters,
|
||||
onSetResources: handleSetResources,
|
||||
onAppendResources: handleAppendResources,
|
||||
onSetLoading: handleSetLoading,
|
||||
});
|
||||
|
||||
// Resource detail drawer
|
||||
const drawer = useResourceDetailDrawer({
|
||||
resources,
|
||||
checkId: group.checkId,
|
||||
});
|
||||
|
||||
const handleDrawerMuteComplete = () => {
|
||||
drawer.closeDrawer();
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
// Selection logic — tracks by findingId (resource_id) for checkbox consistency
|
||||
const selectedFindingIds = Object.keys(rowSelection)
|
||||
.filter((key) => rowSelection[key])
|
||||
.map((idx) => resources[parseInt(idx)]?.findingId)
|
||||
.filter(Boolean);
|
||||
|
||||
// Mute modal state — resource IDs resolved to finding UUIDs on-click
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
const [resolvedFindingIds, setResolvedFindingIds] = useState<string[]>([]);
|
||||
const [isResolvingIds, setIsResolvingIds] = useState(false);
|
||||
|
||||
/** Converts resource_ids (display) → resourceUids → finding UUIDs via API. */
|
||||
const resolveResourceIds = useCallback(
|
||||
async (ids: string[]) => {
|
||||
const resourceUids = ids
|
||||
.map((id) => resources.find((r) => r.findingId === id)?.resourceUid)
|
||||
.filter(Boolean) as string[];
|
||||
if (resourceUids.length === 0) return [];
|
||||
return resolveFindingIds({
|
||||
checkId: group.checkId,
|
||||
resourceUids,
|
||||
});
|
||||
},
|
||||
[resources, group.checkId],
|
||||
);
|
||||
|
||||
const handleMuteClick = async () => {
|
||||
setIsResolvingIds(true);
|
||||
const findingIds = await resolveResourceIds(selectedFindingIds);
|
||||
setResolvedFindingIds(findingIds);
|
||||
setIsResolvingIds(false);
|
||||
if (findingIds.length > 0) {
|
||||
setIsMuteModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const selectableRowCount = resources.filter((r) => !r.isMuted).length;
|
||||
|
||||
const getRowCanSelect = (row: Row<FindingResourceRow>): boolean => {
|
||||
return !row.original.isMuted;
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
setRowSelection({});
|
||||
};
|
||||
|
||||
const isSelected = (id: string) => {
|
||||
return selectedFindingIds.includes(id);
|
||||
};
|
||||
|
||||
const handleMuteComplete = () => {
|
||||
clearSelection();
|
||||
setResolvedFindingIds([]);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const columns = getColumnFindingResources({
|
||||
rowSelection,
|
||||
selectableRowCount,
|
||||
});
|
||||
|
||||
const table = useReactTable({
|
||||
data: resources,
|
||||
columns,
|
||||
enableRowSelection: getRowCanSelect,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onRowSelectionChange: setRowSelection,
|
||||
manualPagination: true,
|
||||
state: {
|
||||
rowSelection,
|
||||
},
|
||||
});
|
||||
|
||||
// Delta for the sticky header
|
||||
const delta =
|
||||
group.newCount > 0
|
||||
? DeltaValues.NEW
|
||||
: group.changedCount > 0
|
||||
? DeltaValues.CHANGED
|
||||
: DeltaValues.NONE;
|
||||
|
||||
const allMuted =
|
||||
group.mutedCount > 0 && group.mutedCount === group.resourcesTotal;
|
||||
|
||||
const rows = table.getRowModel().rows;
|
||||
|
||||
return (
|
||||
<FindingsSelectionContext.Provider
|
||||
value={{
|
||||
selectedFindingIds,
|
||||
selectedFindings: [],
|
||||
clearSelection,
|
||||
isSelected,
|
||||
resolveMuteIds: resolveResourceIds,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary",
|
||||
"flex w-full flex-col overflow-auto border",
|
||||
)}
|
||||
>
|
||||
{/* Sticky header — expanded finding group summary */}
|
||||
<div className="bg-bg-neutral-secondary border-border-neutral-secondary sticky top-0 z-10 border-b p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Back button */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Collapse and go back to findings"
|
||||
className="hover:bg-bg-neutral-tertiary flex size-8 items-center justify-center rounded-md transition-colors"
|
||||
onClick={onCollapse}
|
||||
>
|
||||
<ChevronLeft className="text-text-neutral-secondary size-5" />
|
||||
</button>
|
||||
|
||||
{/* Notification indicator */}
|
||||
<NotificationIndicator delta={delta} isMuted={allMuted} />
|
||||
|
||||
{/* Status badge */}
|
||||
<StatusFindingBadge status={group.status} />
|
||||
|
||||
{/* Finding title */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-text-neutral-primary truncate text-sm font-medium">
|
||||
{group.checkTitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Severity */}
|
||||
<SeverityBadge severity={group.severity} />
|
||||
|
||||
{/* Impacted resources count */}
|
||||
<ImpactedResourcesCell
|
||||
impacted={group.resourcesFail}
|
||||
total={group.resourcesTotal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resources table */}
|
||||
<div className="p-4 pt-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows?.length ? (
|
||||
rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className="cursor-pointer"
|
||||
onClick={() => drawer.openDrawer(row.index)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : !isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No resources found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* Loading indicator */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center gap-2 py-8">
|
||||
<TreeSpinner className="size-6" />
|
||||
<span className="text-text-neutral-tertiary text-sm">
|
||||
Loading resources...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sentinel for infinite scroll */}
|
||||
<div ref={sentinelRef} className="h-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedFindingIds.length > 0 && (
|
||||
<>
|
||||
<MuteFindingsModal
|
||||
isOpen={isMuteModalOpen}
|
||||
onOpenChange={setIsMuteModalOpen}
|
||||
findingIds={resolvedFindingIds}
|
||||
onComplete={handleMuteComplete}
|
||||
/>
|
||||
<div className="animate-in fade-in slide-in-from-bottom-4 fixed right-6 bottom-6 z-50 duration-300">
|
||||
<Button
|
||||
onClick={handleMuteClick}
|
||||
disabled={isResolvingIds}
|
||||
size="lg"
|
||||
className="shadow-lg"
|
||||
>
|
||||
{isResolvingIds ? (
|
||||
<TreeSpinner className="size-5" />
|
||||
) : (
|
||||
<VolumeX className="size-5" />
|
||||
)}
|
||||
Mute ({selectedFindingIds.length})
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ResourceDetailDrawer
|
||||
open={drawer.isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) drawer.closeDrawer();
|
||||
}}
|
||||
isLoading={drawer.isLoading}
|
||||
isNavigating={drawer.isNavigating}
|
||||
checkMeta={drawer.checkMeta}
|
||||
currentIndex={drawer.currentIndex}
|
||||
totalResources={drawer.totalResources}
|
||||
currentFinding={drawer.currentFinding}
|
||||
otherFindings={drawer.otherFindings}
|
||||
onNavigatePrev={drawer.navigatePrev}
|
||||
onNavigateNext={drawer.navigateNext}
|
||||
onMuteComplete={handleDrawerMuteComplete}
|
||||
/>
|
||||
</FindingsSelectionContext.Provider>
|
||||
);
|
||||
}
|
||||
198
ui/components/findings/table/findings-group-table.tsx
Normal file
198
ui/components/findings/table/findings-group-table.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import { Row, RowSelectionState } from "@tanstack/react-table";
|
||||
import { VolumeX } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { resolveFindingIdsByCheckIds } from "@/actions/findings/findings-by-resource";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { FindingGroupRow, MetaDataProps } from "@/types";
|
||||
|
||||
import { MuteFindingsModal } from "../mute-findings-modal";
|
||||
import { getColumnFindingGroups } from "./column-finding-groups";
|
||||
import { FindingsGroupDrillDown } from "./findings-group-drill-down";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
|
||||
interface FindingsGroupTableProps {
|
||||
data: FindingGroupRow[];
|
||||
metadata?: MetaDataProps;
|
||||
}
|
||||
|
||||
export function FindingsGroupTable({
|
||||
data,
|
||||
metadata,
|
||||
}: FindingsGroupTableProps) {
|
||||
const router = useRouter();
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [expandedCheckId, setExpandedCheckId] = useState<string | null>(null);
|
||||
const [expandedGroup, setExpandedGroup] = useState<FindingGroupRow | null>(
|
||||
null,
|
||||
);
|
||||
const [isDrillingDown, setIsDrillingDown] = useState(false);
|
||||
|
||||
// Track finding group IDs to detect data changes (e.g., after muting)
|
||||
const currentIds = (data ?? []).map((g) => g.id).join(",");
|
||||
const previousIdsRef = useRef(currentIds);
|
||||
|
||||
// Reset selection when page changes
|
||||
useEffect(() => {
|
||||
setRowSelection({});
|
||||
}, [metadata?.pagination?.page]);
|
||||
|
||||
// Reset selection and collapse drill-down when data changes (e.g., filters)
|
||||
useEffect(() => {
|
||||
if (previousIdsRef.current !== currentIds) {
|
||||
setRowSelection({});
|
||||
setExpandedCheckId(null);
|
||||
setExpandedGroup(null);
|
||||
previousIdsRef.current = currentIds;
|
||||
}
|
||||
}, [currentIds]);
|
||||
|
||||
const safeData = data ?? [];
|
||||
|
||||
// Get selected group IDs (only non-fully-muted groups can be selected)
|
||||
const selectedFindingIds = Object.keys(rowSelection)
|
||||
.filter((key) => rowSelection[key])
|
||||
.map((idx) => safeData[parseInt(idx)]?.id)
|
||||
.filter(Boolean);
|
||||
|
||||
const selectedFindings = Object.keys(rowSelection)
|
||||
.filter((key) => rowSelection[key])
|
||||
.map((idx) => safeData[parseInt(idx)])
|
||||
.filter(Boolean);
|
||||
|
||||
// Count of selectable rows (groups where not ALL findings are muted)
|
||||
const selectableRowCount = safeData.filter(
|
||||
(g) => !(g.mutedCount > 0 && g.mutedCount === g.resourcesTotal),
|
||||
).length;
|
||||
|
||||
const getRowCanSelect = (row: Row<FindingGroupRow>): boolean => {
|
||||
const group = row.original;
|
||||
return !(group.mutedCount > 0 && group.mutedCount === group.resourcesTotal);
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
setRowSelection({});
|
||||
};
|
||||
|
||||
const isSelected = (id: string) => {
|
||||
return selectedFindingIds.includes(id);
|
||||
};
|
||||
|
||||
// Mute modal state — check IDs resolved to finding UUIDs on-click
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
const [resolvedFindingIds, setResolvedFindingIds] = useState<string[]>([]);
|
||||
const [isResolvingIds, setIsResolvingIds] = useState(false);
|
||||
|
||||
const handleMuteClick = async () => {
|
||||
setIsResolvingIds(true);
|
||||
const findingIds = await resolveFindingIdsByCheckIds({
|
||||
checkIds: selectedFindingIds,
|
||||
});
|
||||
setResolvedFindingIds(findingIds);
|
||||
setIsResolvingIds(false);
|
||||
if (findingIds.length > 0) {
|
||||
setIsMuteModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
/** Shared resolver for row action dropdowns (via context). */
|
||||
const resolveMuteIds = useCallback(
|
||||
async (checkIds: string[]) => resolveFindingIdsByCheckIds({ checkIds }),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMuteComplete = () => {
|
||||
clearSelection();
|
||||
setResolvedFindingIds([]);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleDrillDown = (checkId: string, group: FindingGroupRow) => {
|
||||
setIsDrillingDown(true);
|
||||
setRowSelection({});
|
||||
// Brief loading state before switching to drill-down view
|
||||
setTimeout(() => {
|
||||
setExpandedCheckId(checkId);
|
||||
setExpandedGroup(group);
|
||||
setIsDrillingDown(false);
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const handleCollapse = () => {
|
||||
setExpandedCheckId(null);
|
||||
setExpandedGroup(null);
|
||||
};
|
||||
|
||||
const columns = getColumnFindingGroups({
|
||||
rowSelection,
|
||||
selectableRowCount,
|
||||
onDrillDown: handleDrillDown,
|
||||
});
|
||||
|
||||
// Drill-down mode: show sticky header + resources table
|
||||
if (expandedCheckId && expandedGroup) {
|
||||
return (
|
||||
<FindingsGroupDrillDown
|
||||
group={expandedGroup}
|
||||
onCollapse={handleCollapse}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Normal mode: show finding groups table
|
||||
return (
|
||||
<FindingsSelectionContext.Provider
|
||||
value={{
|
||||
selectedFindingIds,
|
||||
selectedFindings,
|
||||
clearSelection,
|
||||
isSelected,
|
||||
resolveMuteIds,
|
||||
}}
|
||||
>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={safeData}
|
||||
metadata={metadata}
|
||||
enableRowSelection
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
getRowCanSelect={getRowCanSelect}
|
||||
showSearch
|
||||
searchPlaceholder="Search by Check ID"
|
||||
isLoading={isDrillingDown}
|
||||
/>
|
||||
|
||||
{selectedFindingIds.length > 0 && (
|
||||
<>
|
||||
<MuteFindingsModal
|
||||
isOpen={isMuteModalOpen}
|
||||
onOpenChange={setIsMuteModalOpen}
|
||||
findingIds={resolvedFindingIds}
|
||||
onComplete={handleMuteComplete}
|
||||
/>
|
||||
<div className="animate-in fade-in slide-in-from-bottom-4 fixed right-6 bottom-6 z-50 duration-300">
|
||||
<Button
|
||||
onClick={handleMuteClick}
|
||||
disabled={isResolvingIds}
|
||||
size="lg"
|
||||
className="shadow-lg"
|
||||
>
|
||||
{isResolvingIds ? (
|
||||
<TreeSpinner className="size-5" />
|
||||
) : (
|
||||
<VolumeX className="size-5" />
|
||||
)}
|
||||
Mute ({selectedFindingIds.length})
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</FindingsSelectionContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import { FindingProps } from "@/types";
|
||||
|
||||
interface FindingsSelectionContextValue {
|
||||
selectedFindingIds: string[];
|
||||
selectedFindings: FindingProps[];
|
||||
selectedFindings: any[];
|
||||
clearSelection: () => void;
|
||||
isSelected: (id: string) => boolean;
|
||||
/** Resolves display IDs (check_ids or resource_ids) into real finding UUIDs for the mute API. */
|
||||
resolveMuteIds?: (ids: string[]) => Promise<string[]>;
|
||||
}
|
||||
|
||||
export const FindingsSelectionContext =
|
||||
|
||||
68
ui/components/findings/table/impacted-providers-cell.tsx
Normal file
68
ui/components/findings/table/impacted-providers-cell.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { ProviderType } from "@/types";
|
||||
|
||||
import { PROVIDER_ICONS } from "./provider-icon-cell";
|
||||
|
||||
const MAX_VISIBLE_PROVIDERS = 3;
|
||||
const ICON_SIZE = 28;
|
||||
|
||||
interface ImpactedProvidersCellProps {
|
||||
providers: ProviderType[];
|
||||
}
|
||||
|
||||
export const ImpactedProvidersCell = ({
|
||||
providers,
|
||||
}: ImpactedProvidersCellProps) => {
|
||||
if (!providers.length) {
|
||||
return <span className="text-text-neutral-tertiary text-sm">-</span>;
|
||||
}
|
||||
|
||||
const visible = providers.slice(0, MAX_VISIBLE_PROVIDERS);
|
||||
const remaining = providers.length - MAX_VISIBLE_PROVIDERS;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{visible.map((provider) => {
|
||||
const IconComponent = PROVIDER_ICONS[provider];
|
||||
|
||||
if (!IconComponent) {
|
||||
return (
|
||||
<div
|
||||
key={provider}
|
||||
className="flex size-7 items-center justify-center"
|
||||
>
|
||||
<span className="text-text-neutral-secondary text-xs">?</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={provider}
|
||||
className="flex size-7 items-center justify-center overflow-hidden"
|
||||
>
|
||||
<IconComponent width={ICON_SIZE} height={ICON_SIZE} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{remaining > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-text-neutral-tertiary cursor-default text-xs font-medium">
|
||||
+{remaining}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span className="text-xs">
|
||||
{providers.slice(MAX_VISIBLE_PROVIDERS).join(", ")}
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Check, Flag } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "@/components/shadcn";
|
||||
|
||||
export const TriageStatusValues = {
|
||||
IN_PROGRESS: "in_progress",
|
||||
@@ -24,12 +24,7 @@ const TriageBadge = ({ status, count }: TriageBadgeProps) => {
|
||||
const isInProgress = status === TriageStatusValues.IN_PROGRESS;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-sm",
|
||||
"border-border-tag-primary bg-bg-tag-primary text-text-neutral-primary",
|
||||
)}
|
||||
>
|
||||
<Badge variant="tag" className="rounded text-sm">
|
||||
{isInProgress ? (
|
||||
<Flag className="size-3 fill-sky-400 text-sky-400" />
|
||||
) : (
|
||||
@@ -39,7 +34,7 @@ const TriageBadge = ({ status, count }: TriageBadgeProps) => {
|
||||
<span className="font-normal">
|
||||
{isInProgress ? "In-progress" : "Resolved"}
|
||||
</span>
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -58,16 +53,11 @@ export const ImpactedResourcesCell = ({
|
||||
}: ImpactedResourcesCellProps) => {
|
||||
return (
|
||||
<div className="flex items-center gap-6">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-sm",
|
||||
"border-border-tag-primary bg-bg-tag-primary text-text-neutral-primary",
|
||||
)}
|
||||
>
|
||||
<Badge variant="tag" className="rounded text-sm">
|
||||
<span className="font-bold">{impacted}</span>
|
||||
<span className="font-normal">of</span>
|
||||
<span className="font-bold">{total}</span>
|
||||
</span>
|
||||
</Badge>
|
||||
|
||||
{inProgress > 0 && (
|
||||
<TriageBadge
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
export * from "./column-finding-groups";
|
||||
export * from "./column-finding-resources";
|
||||
export * from "./column-findings";
|
||||
export * from "./data-table-row-actions";
|
||||
export * from "./data-table-row-details";
|
||||
export * from "./finding-detail";
|
||||
export * from "./findings-group-drill-down";
|
||||
export * from "./findings-group-table";
|
||||
export * from "./findings-selection-context";
|
||||
export * from "./findings-table-with-selection";
|
||||
// TODO: PROWLER-379 - Re-export when backend supports grouped findings
|
||||
// export * from "./impacted-resources-cell";
|
||||
export * from "./impacted-providers-cell";
|
||||
export * from "./impacted-resources-cell";
|
||||
export * from "./notification-indicator";
|
||||
export * from "./provider-icon-cell";
|
||||
export * from "./skeleton-table-findings";
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ResourceDetailDrawer } from "./resource-detail-drawer";
|
||||
export { useResourceDetailDrawer } from "./use-resource-detail-drawer";
|
||||
@@ -0,0 +1,671 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CircleArrowRight,
|
||||
CircleChevronLeft,
|
||||
CircleChevronRight,
|
||||
VolumeOff,
|
||||
VolumeX,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
import type { ResourceDrawerFinding } from "@/actions/findings";
|
||||
import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
|
||||
import { getComplianceIcon } from "@/components/icons";
|
||||
import {
|
||||
Badge,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/shadcn";
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner";
|
||||
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
import { DateWithTime } from "@/components/ui/entities/date-with-time";
|
||||
import { EntityInfo } from "@/components/ui/entities/entity-info";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { SeverityBadge } from "@/components/ui/table/severity-badge";
|
||||
import {
|
||||
type FindingStatus,
|
||||
StatusFindingBadge,
|
||||
} from "@/components/ui/table/status-finding-badge";
|
||||
import { getRegionFlag } from "@/lib/region-flags";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Muted } from "../../muted";
|
||||
import { NotificationIndicator } from "../notification-indicator";
|
||||
import type { CheckMeta } from "./use-resource-detail-drawer";
|
||||
|
||||
interface ResourceDetailDrawerContentProps {
|
||||
isLoading: boolean;
|
||||
isNavigating: boolean;
|
||||
checkMeta: CheckMeta | null;
|
||||
currentIndex: number;
|
||||
totalResources: number;
|
||||
currentFinding: ResourceDrawerFinding | null;
|
||||
otherFindings: ResourceDrawerFinding[];
|
||||
onNavigatePrev: () => void;
|
||||
onNavigateNext: () => void;
|
||||
onMuteComplete: () => void;
|
||||
}
|
||||
|
||||
const MarkdownContainer = ({ children }: { children: string }) => (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none break-words whitespace-normal">
|
||||
<ReactMarkdown>{children}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
|
||||
function getFailingForLabel(firstSeenAt: string | null): string | null {
|
||||
if (!firstSeenAt) return null;
|
||||
|
||||
const start = new Date(firstSeenAt);
|
||||
if (isNaN(start.getTime())) return null;
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - start.getTime();
|
||||
if (diffMs < 0) return null;
|
||||
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 1) return "< 1 day";
|
||||
if (diffDays < 30) return `${diffDays} day${diffDays > 1 ? "s" : ""}`;
|
||||
|
||||
const diffMonths = Math.floor(diffDays / 30);
|
||||
if (diffMonths < 12) return `${diffMonths} month${diffMonths > 1 ? "s" : ""}`;
|
||||
|
||||
const diffYears = Math.floor(diffMonths / 12);
|
||||
return `${diffYears} year${diffYears > 1 ? "s" : ""}`;
|
||||
}
|
||||
|
||||
export function ResourceDetailDrawerContent({
|
||||
isLoading,
|
||||
isNavigating,
|
||||
checkMeta,
|
||||
currentIndex,
|
||||
totalResources,
|
||||
currentFinding,
|
||||
otherFindings,
|
||||
onNavigatePrev,
|
||||
onNavigateNext,
|
||||
onMuteComplete,
|
||||
}: ResourceDetailDrawerContentProps) {
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
|
||||
// Initial load — no check metadata yet
|
||||
if (!checkMeta && isLoading) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 py-16">
|
||||
<TreeSpinner className="size-6" />
|
||||
<span className="text-text-neutral-tertiary text-sm">
|
||||
Loading finding details...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!checkMeta) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center py-16">
|
||||
<p className="text-text-neutral-tertiary text-sm">
|
||||
No finding data available for this resource.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// checkMeta is always available from here.
|
||||
// currentFinding may be null during resource loading (e.g. drawer reopen).
|
||||
const f = currentFinding;
|
||||
const hasPrev = currentIndex > 0;
|
||||
const hasNext = currentIndex < totalResources - 1;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
|
||||
{/* Mute modal — rendered outside drawer content to avoid overlay conflicts */}
|
||||
{f && !f.isMuted && (
|
||||
<MuteFindingsModal
|
||||
isOpen={isMuteModalOpen}
|
||||
onOpenChange={setIsMuteModalOpen}
|
||||
findingIds={[f.id]}
|
||||
onComplete={() => {
|
||||
setIsMuteModalOpen(false);
|
||||
onMuteComplete();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header: status badges + title (check-level from checkMeta) */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{f && <StatusFindingBadge status={f.status as FindingStatus} />}
|
||||
{f && <SeverityBadge severity={f.severity} />}
|
||||
{f?.delta && (
|
||||
<div className="flex items-center gap-1 capitalize">
|
||||
<div
|
||||
className={cn(
|
||||
"size-2 rounded-full",
|
||||
f.delta === "new"
|
||||
? "bg-system-severity-high"
|
||||
: "bg-system-severity-low",
|
||||
)}
|
||||
/>
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
{f.delta}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{f && (
|
||||
<Muted
|
||||
isMuted={f.isMuted}
|
||||
mutedReason={f.mutedReason || "This finding is muted"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 className="text-text-neutral-primary line-clamp-2 text-lg leading-tight font-medium">
|
||||
{checkMeta.checkTitle}
|
||||
</h2>
|
||||
|
||||
{checkMeta.complianceFrameworks.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-text-neutral-tertiary text-xs font-medium">
|
||||
Compliance Frameworks:
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{checkMeta.complianceFrameworks.map((framework) => {
|
||||
const icon = getComplianceIcon(framework);
|
||||
return icon ? (
|
||||
<Image
|
||||
key={framework}
|
||||
src={icon}
|
||||
alt={framework}
|
||||
width={20}
|
||||
height={20}
|
||||
className="shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
key={framework}
|
||||
className="text-text-neutral-secondary text-xs"
|
||||
>
|
||||
{framework}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation: "Impacted Resource (X of N)" */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="tag" className="rounded text-sm">
|
||||
Impacted Resource
|
||||
<span className="font-bold">{currentIndex + 1}</span>
|
||||
<span className="font-normal">of</span>
|
||||
<span className="font-bold">{totalResources}</span>
|
||||
</Badge>
|
||||
<div className="flex items-center gap-0">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasPrev}
|
||||
onClick={onNavigatePrev}
|
||||
className="hover:bg-bg-neutral-tertiary disabled:text-text-neutral-tertiary flex size-8 items-center justify-center rounded-md transition-colors disabled:cursor-not-allowed disabled:hover:bg-transparent"
|
||||
aria-label="Previous resource"
|
||||
>
|
||||
<CircleChevronLeft className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasNext}
|
||||
onClick={onNavigateNext}
|
||||
className="hover:bg-bg-neutral-tertiary disabled:text-text-neutral-tertiary flex size-8 items-center justify-center rounded-md transition-colors disabled:cursor-not-allowed disabled:hover:bg-transparent"
|
||||
aria-label="Next resource"
|
||||
>
|
||||
<CircleChevronRight className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resource card */}
|
||||
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex min-h-0 flex-1 flex-col gap-4 overflow-hidden rounded-lg border p-4">
|
||||
{/* Resource info — shows loading when currentFinding is not yet available */}
|
||||
{!f || isNavigating ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<TreeSpinner className="size-5" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Account, Resource, Service, Region, Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<EntityInfo
|
||||
cloudProvider={f.providerType}
|
||||
entityAlias={f.providerAlias}
|
||||
entityId={f.providerUid}
|
||||
/>
|
||||
<EntityInfo
|
||||
entityAlias={f.resourceType}
|
||||
entityId={f.resourceUid}
|
||||
idLabel="UID"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-[10px]">
|
||||
Service
|
||||
</span>
|
||||
<span className="text-text-neutral-primary text-sm">
|
||||
{f.resourceService}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-[10px]">
|
||||
Region
|
||||
</span>
|
||||
<span className="text-text-neutral-primary flex items-center gap-1.5 text-sm">
|
||||
{getRegionFlag(f.resourceRegion) && (
|
||||
<span className="translate-y-px text-base leading-none">
|
||||
{getRegionFlag(f.resourceRegion)}
|
||||
</span>
|
||||
)}
|
||||
{f.resourceRegion}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<ActionDropdown ariaLabel="Resource actions">
|
||||
<ActionDropdownItem
|
||||
icon={
|
||||
f.isMuted ? (
|
||||
<VolumeOff className="size-5" />
|
||||
) : (
|
||||
<VolumeX className="size-5" />
|
||||
)
|
||||
}
|
||||
label={f.isMuted ? "Muted" : "Mute"}
|
||||
disabled={f.isMuted}
|
||||
onSelect={() => setIsMuteModalOpen(true)}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dates row */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-[10px]">
|
||||
Last detected
|
||||
</span>
|
||||
<DateWithTime inline dateTime={f.updatedAt || "-"} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-[10px]">
|
||||
First seen
|
||||
</span>
|
||||
<DateWithTime inline dateTime={f.firstSeenAt || "-"} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-[10px]">
|
||||
Failing for
|
||||
</span>
|
||||
<span className="text-text-neutral-primary text-sm">
|
||||
{getFailingForLabel(f.firstSeenAt) || "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* IDs row */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-[10px]">
|
||||
Check ID
|
||||
</span>
|
||||
<CodeSnippet
|
||||
value={checkMeta.checkId}
|
||||
transparent
|
||||
className="max-w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-[10px]">
|
||||
Finding ID
|
||||
</span>
|
||||
<CodeSnippet
|
||||
value={f.id}
|
||||
transparent
|
||||
className="max-w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-[10px]">
|
||||
Finding UID
|
||||
</span>
|
||||
<CodeSnippet
|
||||
value={f.uid}
|
||||
transparent
|
||||
className="max-w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
defaultValue="overview"
|
||||
className="flex min-h-0 w-full flex-1 flex-col"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Finding Overview</TabsTrigger>
|
||||
<TabsTrigger value="other-findings">
|
||||
Other Findings For This Resource
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* Finding Overview — check-level data from checkMeta (always stable) */}
|
||||
<TabsContent
|
||||
value="overview"
|
||||
className="minimal-scrollbar flex flex-col gap-4 overflow-y-auto"
|
||||
>
|
||||
{/* Card 1: Risk + Description + Status Extended */}
|
||||
{(checkMeta.risk || checkMeta.description || f?.statusExtended) && (
|
||||
<Card variant="inner">
|
||||
{checkMeta.risk && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
Risk:
|
||||
</span>
|
||||
<MarkdownContainer>{checkMeta.risk}</MarkdownContainer>
|
||||
</div>
|
||||
)}
|
||||
{checkMeta.description && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
Description:
|
||||
</span>
|
||||
<MarkdownContainer>
|
||||
{checkMeta.description}
|
||||
</MarkdownContainer>
|
||||
</div>
|
||||
)}
|
||||
{f?.statusExtended && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
Status Extended:
|
||||
</span>
|
||||
<p className="text-text-neutral-primary text-sm">
|
||||
{f.statusExtended}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Card 2: Remediation + Commands */}
|
||||
{(checkMeta.remediation.recommendation.text ||
|
||||
checkMeta.remediation.code.cli ||
|
||||
checkMeta.remediation.code.terraform ||
|
||||
checkMeta.remediation.code.nativeiac) && (
|
||||
<Card variant="inner">
|
||||
{checkMeta.remediation.recommendation.text && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
Remediation:
|
||||
</span>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-text-neutral-primary flex-1 text-sm">
|
||||
<MarkdownContainer>
|
||||
{checkMeta.remediation.recommendation.text}
|
||||
</MarkdownContainer>
|
||||
</div>
|
||||
{checkMeta.remediation.recommendation.url && (
|
||||
<CustomLink
|
||||
href={checkMeta.remediation.recommendation.url}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
>
|
||||
View in Prowler Hub
|
||||
</CustomLink>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkMeta.remediation.code.cli && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
CLI Command:
|
||||
</span>
|
||||
<CodeSnippet
|
||||
value={`$ ${checkMeta.remediation.code.cli}`}
|
||||
multiline
|
||||
transparent
|
||||
className="max-w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkMeta.remediation.code.terraform && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
Terraform Command:
|
||||
</span>
|
||||
<CodeSnippet
|
||||
value={`$ ${checkMeta.remediation.code.terraform}`}
|
||||
multiline
|
||||
transparent
|
||||
className="max-w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkMeta.remediation.code.nativeiac && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
CloudFormation Command:
|
||||
</span>
|
||||
<CodeSnippet
|
||||
value={`$ ${checkMeta.remediation.code.nativeiac}`}
|
||||
multiline
|
||||
transparent
|
||||
className="max-w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkMeta.remediation.code.other && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
Remediation Steps:
|
||||
</span>
|
||||
<MarkdownContainer>
|
||||
{checkMeta.remediation.code.other}
|
||||
</MarkdownContainer>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{checkMeta.additionalUrls.length > 0 && (
|
||||
<Card variant="inner">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
References:
|
||||
</span>
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
{checkMeta.additionalUrls.map((link, idx) => (
|
||||
<li key={idx}>
|
||||
<CustomLink
|
||||
href={link}
|
||||
size="sm"
|
||||
className="break-all whitespace-normal!"
|
||||
>
|
||||
{link}
|
||||
</CustomLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{checkMeta.categories.length > 0 && (
|
||||
<Card variant="inner">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
Categories:
|
||||
</span>
|
||||
<p className="text-text-neutral-primary text-sm">
|
||||
{checkMeta.categories.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Other Findings For This Resource */}
|
||||
<TabsContent
|
||||
value="other-findings"
|
||||
className="minimal-scrollbar flex flex-col gap-2 overflow-y-auto"
|
||||
>
|
||||
{!f || isNavigating ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<TreeSpinner className="size-5" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-text-neutral-primary text-sm font-medium">
|
||||
Failed Findings For This Resource
|
||||
</h4>
|
||||
<span className="text-text-neutral-tertiary text-sm">
|
||||
{otherFindings.length} Total Entries
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10" />
|
||||
<TableHead>
|
||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
||||
Status
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
||||
Finding
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
||||
Severity
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
||||
Time
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="w-10" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{otherFindings.length > 0 ? (
|
||||
otherFindings.map((finding) => (
|
||||
<OtherFindingRow key={finding.id} finding={finding} />
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-16 text-center">
|
||||
<span className="text-text-neutral-tertiary text-sm">
|
||||
No other findings for this resource.
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Lighthouse AI button */}
|
||||
<a
|
||||
href={`/lighthouse?${new URLSearchParams({ prompt: `Analyze this security finding and provide remediation guidance:\n\n- **Finding**: ${checkMeta.checkTitle}\n- **Check ID**: ${checkMeta.checkId}\n- **Severity**: ${f?.severity ?? "unknown"}\n- **Status**: ${f?.status ?? "unknown"}${f?.statusExtended ? `\n- **Detail**: ${f.statusExtended}` : ""}${checkMeta.risk ? `\n- **Risk**: ${checkMeta.risk}` : ""}` }).toString()}`}
|
||||
className="flex items-center gap-1.5 rounded-lg px-4 py-3 text-sm font-bold text-slate-950 transition-opacity hover:opacity-90"
|
||||
style={{
|
||||
background: "linear-gradient(96deg, #2EE59B 3.55%, #62DFF0 98.85%)",
|
||||
}}
|
||||
>
|
||||
<CircleArrowRight className="size-5" />
|
||||
View This Finding With Lighthouse AI
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OtherFindingRow({ finding }: { finding: ResourceDrawerFinding }) {
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MuteFindingsModal
|
||||
isOpen={isMuteModalOpen}
|
||||
onOpenChange={setIsMuteModalOpen}
|
||||
findingIds={[finding.id]}
|
||||
/>
|
||||
<TableRow>
|
||||
<TableCell className="w-10">
|
||||
<NotificationIndicator isMuted={finding.isMuted} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusFindingBadge status={finding.status as FindingStatus} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="text-text-neutral-primary max-w-[300px] truncate text-sm">
|
||||
{finding.checkTitle}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SeverityBadge severity={finding.severity} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DateWithTime dateTime={finding.updatedAt} />
|
||||
</TableCell>
|
||||
<TableCell className="w-10">
|
||||
<ActionDropdown ariaLabel="Finding actions">
|
||||
<ActionDropdownItem
|
||||
icon={
|
||||
finding.isMuted ? (
|
||||
<VolumeOff className="size-5" />
|
||||
) : (
|
||||
<VolumeX className="size-5" />
|
||||
)
|
||||
}
|
||||
label={finding.isMuted ? "Muted" : "Mute"}
|
||||
disabled={finding.isMuted}
|
||||
onSelect={() => setIsMuteModalOpen(true)}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import type { ResourceDrawerFinding } from "@/actions/findings";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/shadcn";
|
||||
|
||||
import { ResourceDetailDrawerContent } from "./resource-detail-drawer-content";
|
||||
import type { CheckMeta } from "./use-resource-detail-drawer";
|
||||
|
||||
interface ResourceDetailDrawerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
isLoading: boolean;
|
||||
isNavigating: boolean;
|
||||
checkMeta: CheckMeta | null;
|
||||
currentIndex: number;
|
||||
totalResources: number;
|
||||
currentFinding: ResourceDrawerFinding | null;
|
||||
otherFindings: ResourceDrawerFinding[];
|
||||
onNavigatePrev: () => void;
|
||||
onNavigateNext: () => void;
|
||||
onMuteComplete: () => void;
|
||||
}
|
||||
|
||||
export function ResourceDetailDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
isLoading,
|
||||
isNavigating,
|
||||
checkMeta,
|
||||
currentIndex,
|
||||
totalResources,
|
||||
currentFinding,
|
||||
otherFindings,
|
||||
onNavigatePrev,
|
||||
onNavigateNext,
|
||||
onMuteComplete,
|
||||
}: ResourceDetailDrawerProps) {
|
||||
return (
|
||||
<Drawer direction="right" open={open} onOpenChange={onOpenChange}>
|
||||
<DrawerContent className="3xl:w-1/3 h-full w-full overflow-hidden p-6 outline-none md:w-1/2 md:max-w-none md:min-w-[720px]">
|
||||
<DrawerHeader className="sr-only">
|
||||
<DrawerTitle>Resource Finding Details</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
View finding details for the selected resource
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<DrawerClose className="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none">
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DrawerClose>
|
||||
{open && (
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={isLoading}
|
||||
isNavigating={isNavigating}
|
||||
checkMeta={checkMeta}
|
||||
currentIndex={currentIndex}
|
||||
totalResources={totalResources}
|
||||
currentFinding={currentFinding}
|
||||
otherFindings={otherFindings}
|
||||
onNavigatePrev={onNavigatePrev}
|
||||
onNavigateNext={onNavigateNext}
|
||||
onMuteComplete={onMuteComplete}
|
||||
/>
|
||||
)}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
adaptFindingsByResourceResponse,
|
||||
getLatestFindingsByResourceUid,
|
||||
type ResourceDrawerFinding,
|
||||
} from "@/actions/findings";
|
||||
import { FindingResourceRow } from "@/types";
|
||||
|
||||
/**
|
||||
* Check-level metadata that is identical across all resources for a given check.
|
||||
* Extracted once on first successful fetch and kept stable during navigation.
|
||||
*/
|
||||
export interface CheckMeta {
|
||||
checkId: string;
|
||||
checkTitle: string;
|
||||
risk: string;
|
||||
description: string;
|
||||
complianceFrameworks: string[];
|
||||
categories: string[];
|
||||
remediation: ResourceDrawerFinding["remediation"];
|
||||
additionalUrls: string[];
|
||||
}
|
||||
|
||||
function extractCheckMeta(finding: ResourceDrawerFinding): CheckMeta {
|
||||
return {
|
||||
checkId: finding.checkId,
|
||||
checkTitle: finding.checkTitle,
|
||||
risk: finding.risk,
|
||||
description: finding.description,
|
||||
complianceFrameworks: finding.complianceFrameworks,
|
||||
categories: finding.categories,
|
||||
remediation: finding.remediation,
|
||||
additionalUrls: finding.additionalUrls,
|
||||
};
|
||||
}
|
||||
|
||||
interface UseResourceDetailDrawerOptions {
|
||||
resources: FindingResourceRow[];
|
||||
checkId: string;
|
||||
onRequestMoreResources?: () => void;
|
||||
}
|
||||
|
||||
interface UseResourceDetailDrawerReturn {
|
||||
isOpen: boolean;
|
||||
isLoading: boolean;
|
||||
isNavigating: boolean;
|
||||
checkMeta: CheckMeta | null;
|
||||
currentIndex: number;
|
||||
totalResources: number;
|
||||
currentFinding: ResourceDrawerFinding | null;
|
||||
otherFindings: ResourceDrawerFinding[];
|
||||
allFindings: ResourceDrawerFinding[];
|
||||
openDrawer: (index: number) => void;
|
||||
closeDrawer: () => void;
|
||||
navigatePrev: () => void;
|
||||
navigateNext: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the resource detail drawer state, fetching, and navigation.
|
||||
*
|
||||
* Caches findings per resourceUid in a Map ref so navigating prev/next
|
||||
* doesn't re-fetch already-visited resources.
|
||||
*/
|
||||
export function useResourceDetailDrawer({
|
||||
resources,
|
||||
checkId,
|
||||
onRequestMoreResources,
|
||||
}: UseResourceDetailDrawerOptions): UseResourceDetailDrawerReturn {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [findings, setFindings] = useState<ResourceDrawerFinding[]>([]);
|
||||
const [isNavigating, setIsNavigating] = useState(false);
|
||||
|
||||
const cacheRef = useRef<Map<string, ResourceDrawerFinding[]>>(new Map());
|
||||
const checkMetaRef = useRef<CheckMeta | null>(null);
|
||||
|
||||
const fetchFindings = async (resourceUid: string) => {
|
||||
// Check cache first
|
||||
const cached = cacheRef.current.get(resourceUid);
|
||||
if (cached) {
|
||||
if (!checkMetaRef.current) {
|
||||
const main = cached.find((f) => f.checkId === checkId) ?? cached[0];
|
||||
if (main) checkMetaRef.current = extractCheckMeta(main);
|
||||
}
|
||||
setFindings(cached);
|
||||
setIsLoading(false);
|
||||
setIsNavigating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await getLatestFindingsByResourceUid({ resourceUid });
|
||||
const adapted = adaptFindingsByResourceResponse(response);
|
||||
cacheRef.current.set(resourceUid, adapted);
|
||||
|
||||
// Extract check-level metadata once (stable across all resources)
|
||||
if (!checkMetaRef.current) {
|
||||
const main = adapted.find((f) => f.checkId === checkId) ?? adapted[0];
|
||||
if (main) checkMetaRef.current = extractCheckMeta(main);
|
||||
}
|
||||
|
||||
setFindings(adapted);
|
||||
} catch (error) {
|
||||
console.error("Error fetching findings for resource:", error);
|
||||
// Don't clear findings — keep previous data as fallback during navigation
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsNavigating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openDrawer = (index: number) => {
|
||||
const resource = resources[index];
|
||||
if (!resource) return;
|
||||
|
||||
setCurrentIndex(index);
|
||||
setIsOpen(true);
|
||||
setFindings([]);
|
||||
fetchFindings(resource.resourceUid);
|
||||
};
|
||||
|
||||
const closeDrawer = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const navigateTo = (index: number) => {
|
||||
const resource = resources[index];
|
||||
if (!resource) return;
|
||||
|
||||
setCurrentIndex(index);
|
||||
setIsNavigating(true);
|
||||
fetchFindings(resource.resourceUid);
|
||||
};
|
||||
|
||||
const navigatePrev = () => {
|
||||
if (currentIndex > 0) {
|
||||
navigateTo(currentIndex - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateNext = () => {
|
||||
if (currentIndex < resources.length - 1) {
|
||||
navigateTo(currentIndex + 1);
|
||||
|
||||
// Pre-fetch more resources when nearing the end
|
||||
if (currentIndex >= resources.length - 3) {
|
||||
onRequestMoreResources?.();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// The finding whose checkId matches the drill-down's checkId
|
||||
const currentFinding =
|
||||
findings.find((f) => f.checkId === checkId) ?? findings[0] ?? null;
|
||||
|
||||
// All other findings for this resource
|
||||
const otherFindings = currentFinding
|
||||
? findings.filter((f) => f.id !== currentFinding.id)
|
||||
: findings;
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
isLoading,
|
||||
isNavigating,
|
||||
checkMeta: checkMetaRef.current,
|
||||
currentIndex,
|
||||
totalResources: resources.length,
|
||||
currentFinding,
|
||||
otherFindings,
|
||||
allFindings: findings,
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
navigatePrev,
|
||||
navigateNext,
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,10 @@ const SkeletonTableRow = () => {
|
||||
<Skeleton className="size-1.5 rounded-full" />
|
||||
</div>
|
||||
</td>
|
||||
{/* Expand chevron */}
|
||||
<td className="px-1 py-4">
|
||||
<Skeleton className="size-5 rounded" />
|
||||
</td>
|
||||
{/* Checkbox */}
|
||||
<td className="px-2 py-4">
|
||||
<div className="bg-bg-input-primary border-border-input-primary size-6 rounded-sm border shadow-[0_1px_2px_0_rgba(0,0,0,0.1)]" />
|
||||
@@ -24,14 +28,6 @@ const SkeletonTableRow = () => {
|
||||
<Skeleton className="h-4 w-4/5 rounded" />
|
||||
</div>
|
||||
</td>
|
||||
{/* Resource name chip */}
|
||||
<td className="px-3 py-4">
|
||||
<div className="bg-bg-neutral-tertiary flex h-8 w-28 items-center gap-2 rounded-lg px-2">
|
||||
<Skeleton className="size-4 rounded" />
|
||||
<Skeleton className="h-3.5 w-16 rounded" />
|
||||
<Skeleton className="ml-auto size-3.5 rounded" />
|
||||
</div>
|
||||
</td>
|
||||
{/* Severity */}
|
||||
<td className="px-3 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -39,21 +35,17 @@ const SkeletonTableRow = () => {
|
||||
<Skeleton className="h-4 w-12 rounded" />
|
||||
</div>
|
||||
</td>
|
||||
{/* Provider icon */}
|
||||
{/* Provider icons */}
|
||||
<td className="px-3 py-4">
|
||||
<Skeleton className="size-9 rounded-lg" />
|
||||
</td>
|
||||
{/* Service */}
|
||||
<td className="px-3 py-4">
|
||||
<Skeleton className="h-4 w-20 rounded" />
|
||||
</td>
|
||||
{/* Time */}
|
||||
<td className="px-3 py-4">
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-4 w-24 rounded" />
|
||||
<Skeleton className="h-3 w-20 rounded" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Skeleton className="size-7 rounded-md" />
|
||||
<Skeleton className="size-7 rounded-md" />
|
||||
</div>
|
||||
</td>
|
||||
{/* Resources badge */}
|
||||
<td className="px-3 py-4">
|
||||
<Skeleton className="h-6 w-16 rounded-md" />
|
||||
</td>
|
||||
{/* Actions */}
|
||||
<td className="px-2 py-4">
|
||||
<Skeleton className="size-6 rounded" />
|
||||
@@ -81,6 +73,8 @@ export const SkeletonTableFindings = () => {
|
||||
<tr className="border-border-neutral-secondary border-b">
|
||||
{/* Notification - empty header */}
|
||||
<th className="w-6 py-3" />
|
||||
{/* Expand - empty header */}
|
||||
<th className="w-8 py-3" />
|
||||
{/* Checkbox */}
|
||||
<th className="w-10 px-2 py-3">
|
||||
<div className="bg-bg-input-primary border-border-input-primary size-6 rounded-sm border shadow-[0_1px_2px_0_rgba(0,0,0,0.1)]" />
|
||||
@@ -93,25 +87,17 @@ export const SkeletonTableFindings = () => {
|
||||
<th className="w-[300px] px-3 py-3 text-left">
|
||||
<Skeleton className="h-4 w-14 rounded" />
|
||||
</th>
|
||||
{/* Resource name */}
|
||||
<th className="px-3 py-3 text-left">
|
||||
<Skeleton className="h-4 w-24 rounded" />
|
||||
</th>
|
||||
{/* Severity */}
|
||||
<th className="px-3 py-3 text-left">
|
||||
<Skeleton className="h-4 w-14 rounded" />
|
||||
</th>
|
||||
{/* Provider */}
|
||||
{/* Providers */}
|
||||
<th className="px-3 py-3 text-left">
|
||||
<Skeleton className="h-4 w-14 rounded" />
|
||||
<Skeleton className="h-4 w-16 rounded" />
|
||||
</th>
|
||||
{/* Service */}
|
||||
{/* Resources */}
|
||||
<th className="px-3 py-3 text-left">
|
||||
<Skeleton className="h-4 w-12 rounded" />
|
||||
</th>
|
||||
{/* Time */}
|
||||
<th className="px-3 py-3 text-left">
|
||||
<Skeleton className="h-4 w-10 rounded" />
|
||||
<Skeleton className="h-4 w-16 rounded" />
|
||||
</th>
|
||||
{/* Actions - empty header */}
|
||||
<th className="w-10 py-3" />
|
||||
|
||||
@@ -61,6 +61,7 @@ interface ChatProps {
|
||||
providers: Provider[];
|
||||
defaultProviderId?: LighthouseProvider;
|
||||
defaultModelId?: string;
|
||||
initialPrompt?: string;
|
||||
}
|
||||
|
||||
interface SelectedModel {
|
||||
@@ -102,6 +103,7 @@ export const Chat = ({
|
||||
providers: initialProviders,
|
||||
defaultProviderId,
|
||||
defaultModelId,
|
||||
initialPrompt,
|
||||
}: ChatProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -306,6 +308,15 @@ export const Chat = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-send initial prompt from URL (e.g., finding context from drawer)
|
||||
const initialPromptSentRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (initialPrompt && !initialPromptSentRef.current) {
|
||||
initialPromptSentRef.current = true;
|
||||
sendMessage({ text: initialPrompt });
|
||||
}
|
||||
}, [initialPrompt, sendMessage]);
|
||||
|
||||
// Handlers
|
||||
const handleNewChat = () => {
|
||||
setMessages([]);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { ComponentProps, ReactNode } from "react";
|
||||
|
||||
import { VerticalDotsIcon } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
@@ -41,7 +41,10 @@ export function ActionDropdown({
|
||||
aria-label={ariaLabel}
|
||||
className="hover:bg-bg-neutral-tertiary rounded-md p-1 transition-colors"
|
||||
>
|
||||
<MoreHorizontal className="text-text-neutral-secondary size-5" />
|
||||
<VerticalDotsIcon
|
||||
size={20}
|
||||
className="text-text-neutral-secondary"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* Trigger component style parts using semantic class names
|
||||
*/
|
||||
const TRIGGER_STYLES = {
|
||||
base: "relative inline-flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50",
|
||||
border: "border-r border-[#E9E9F0] last:border-r-0 dark:border-[#171D30]",
|
||||
text: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white",
|
||||
active:
|
||||
"data-[state=active]:text-slate-900 dark:data-[state=active]:text-white",
|
||||
underline:
|
||||
"after:absolute after:bottom-0 after:left-1/2 after:h-0.5 after:w-0 after:-translate-x-1/2 after:bg-emerald-400 after:transition-all data-[state=active]:after:w-[calc(100%-theme(spacing.5))]",
|
||||
focus:
|
||||
"focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white focus-visible:outline-none dark:focus-visible:ring-offset-slate-950",
|
||||
icon: "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Content component styles
|
||||
*/
|
||||
export const CONTENT_STYLES =
|
||||
"mt-2 focus-visible:rounded-md focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:border-ring focus-visible:outline-ring focus-visible:ring-ring/50" as const;
|
||||
|
||||
/**
|
||||
* Build trigger className by combining style parts
|
||||
*/
|
||||
export function buildTriggerClassName(): string {
|
||||
return [
|
||||
TRIGGER_STYLES.base,
|
||||
TRIGGER_STYLES.border,
|
||||
TRIGGER_STYLES.text,
|
||||
TRIGGER_STYLES.active,
|
||||
TRIGGER_STYLES.underline,
|
||||
TRIGGER_STYLES.focus,
|
||||
TRIGGER_STYLES.icon,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build list className
|
||||
*/
|
||||
export function buildListClassName(): string {
|
||||
return "inline-flex w-full items-center border-[#E9E9F0] dark:border-[#171D30]";
|
||||
}
|
||||
@@ -5,11 +5,49 @@ import type { ComponentProps } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
buildListClassName,
|
||||
buildTriggerClassName,
|
||||
CONTENT_STYLES,
|
||||
} from "./tabs.constants";
|
||||
/**
|
||||
* Trigger component style parts using semantic class names
|
||||
*/
|
||||
const TRIGGER_STYLES = {
|
||||
base: "relative inline-flex items-center justify-center gap-2 py-3 text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&:not(:first-child)]:pl-4 [&:not(:last-child)]:pr-4",
|
||||
border: "border-r border-[#E9E9F0] last:border-r-0 dark:border-[#171D30]",
|
||||
text: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white",
|
||||
active:
|
||||
"data-[state=active]:text-slate-900 dark:data-[state=active]:text-white",
|
||||
underline:
|
||||
"after:absolute after:bottom-0 after:left-0 after:right-4 after:h-0.5 after:scale-x-0 after:bg-emerald-400 after:transition-transform data-[state=active]:after:scale-x-100 [&:not(:first-child)]:after:left-4 [&:last-child]:after:right-0",
|
||||
focus:
|
||||
"focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white focus-visible:outline-none dark:focus-visible:ring-offset-slate-950",
|
||||
icon: "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Content component styles
|
||||
*/
|
||||
const CONTENT_STYLES =
|
||||
"mt-2 focus-visible:rounded-md focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:border-ring focus-visible:outline-ring focus-visible:ring-ring/50" as const;
|
||||
|
||||
/**
|
||||
* Build trigger className by combining style parts
|
||||
*/
|
||||
function buildTriggerClassName(): string {
|
||||
return [
|
||||
TRIGGER_STYLES.base,
|
||||
TRIGGER_STYLES.border,
|
||||
TRIGGER_STYLES.text,
|
||||
TRIGGER_STYLES.active,
|
||||
TRIGGER_STYLES.underline,
|
||||
TRIGGER_STYLES.focus,
|
||||
TRIGGER_STYLES.icon,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build list className
|
||||
*/
|
||||
function buildListClassName(): string {
|
||||
return "inline-flex w-full items-center border-[#E9E9F0] dark:border-[#171D30]";
|
||||
}
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
|
||||
@@ -23,6 +23,8 @@ interface CodeSnippetProps {
|
||||
formatter?: (value: string) => string;
|
||||
/** Enable multiline display (disables truncation, enables word wrap) */
|
||||
multiline?: boolean;
|
||||
/** Remove background and border */
|
||||
transparent?: boolean;
|
||||
/** Custom aria-label for the copy button */
|
||||
ariaLabel?: string;
|
||||
}
|
||||
@@ -34,6 +36,7 @@ export const CodeSnippet = ({
|
||||
hideCopyButton = false,
|
||||
icon,
|
||||
formatter,
|
||||
transparent = false,
|
||||
multiline = false,
|
||||
ariaLabel = "Copy to clipboard",
|
||||
}: CodeSnippetProps) => {
|
||||
@@ -86,8 +89,15 @@ export const CodeSnippet = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-bg-neutral-tertiary text-text-neutral-primary border-border-neutral-tertiary flex w-fit min-w-0 items-center gap-1.5 border-2 px-2 py-0.5 text-xs",
|
||||
multiline ? "h-auto rounded-lg" : "h-6 rounded-full",
|
||||
"flex w-fit min-w-0 items-center gap-1.5 text-xs",
|
||||
transparent
|
||||
? "text-text-neutral-tertiary border-0 bg-transparent px-0 py-0"
|
||||
: "text-text-neutral-primary bg-bg-neutral-tertiary border-border-neutral-tertiary border-2 px-2 py-0.5",
|
||||
multiline
|
||||
? "h-auto rounded-lg"
|
||||
: transparent
|
||||
? "h-auto"
|
||||
: "h-6 rounded-full",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -18,6 +18,8 @@ interface EntityInfoProps {
|
||||
entityAlias?: string;
|
||||
entityId?: string;
|
||||
badge?: string;
|
||||
/** Label before the ID value. Defaults to "UID" */
|
||||
idLabel?: string;
|
||||
showCopyAction?: boolean;
|
||||
/** @deprecated No longer used — layout handles overflow naturally */
|
||||
maxWidth?: string;
|
||||
@@ -33,6 +35,7 @@ export const EntityInfo = ({
|
||||
entityAlias,
|
||||
entityId,
|
||||
badge,
|
||||
idLabel = "UID",
|
||||
showCopyAction = true,
|
||||
}: EntityInfoProps) => {
|
||||
const canCopy = Boolean(entityId && showCopyAction);
|
||||
@@ -64,7 +67,7 @@ export const EntityInfo = ({
|
||||
{entityId && (
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className="text-text-neutral-tertiary shrink-0 text-xs font-medium">
|
||||
UID:
|
||||
{idLabel}:
|
||||
</span>
|
||||
<CodeSnippet
|
||||
value={entityId}
|
||||
|
||||
@@ -21,12 +21,14 @@ interface DataTableSearchProps {
|
||||
*/
|
||||
controlledValue?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const DataTableSearch = ({
|
||||
paramPrefix = "",
|
||||
controlledValue,
|
||||
onSearchChange,
|
||||
placeholder = "Search...",
|
||||
}: DataTableSearchProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
@@ -201,7 +203,7 @@ export const DataTableSearch = ({
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
type="search"
|
||||
placeholder="Search..."
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onFocus={handleFocus}
|
||||
|
||||
@@ -96,6 +96,8 @@ interface DataTableProviderProps<TData, TValue> {
|
||||
onPageSizeChange?: (pageSize: number) => void;
|
||||
/** Show loading state with opacity overlay (for controlled mode) */
|
||||
isLoading?: boolean;
|
||||
/** Custom placeholder text for the search input */
|
||||
searchPlaceholder?: string;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
@@ -121,6 +123,7 @@ export function DataTable<TData, TValue>({
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
isLoading = false,
|
||||
searchPlaceholder,
|
||||
}: DataTableProviderProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
@@ -213,6 +216,7 @@ export function DataTable<TData, TValue>({
|
||||
paramPrefix={paramPrefix}
|
||||
controlledValue={controlledSearch}
|
||||
onSearchChange={onSearchChange}
|
||||
placeholder={searchPlaceholder}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
172
ui/hooks/use-infinite-resources.ts
Normal file
172
ui/hooks/use-infinite-resources.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import {
|
||||
adaptFindingGroupResourcesResponse,
|
||||
getFindingGroupResources,
|
||||
getLatestFindingGroupResources,
|
||||
} from "@/actions/finding-groups";
|
||||
import { FindingResourceRow } from "@/types";
|
||||
|
||||
const RESOURCES_PAGE_SIZE = 20;
|
||||
|
||||
interface UseInfiniteResourcesOptions {
|
||||
checkId: string;
|
||||
hasDateOrScanFilter: boolean;
|
||||
filters: Record<string, string | string[] | undefined>;
|
||||
onSetResources: (resources: FindingResourceRow[], hasMore: boolean) => void;
|
||||
onAppendResources: (
|
||||
resources: FindingResourceRow[],
|
||||
hasMore: boolean,
|
||||
) => void;
|
||||
onSetLoading: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
interface UseInfiniteResourcesReturn {
|
||||
sentinelRef: (node: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for paginated infinite-scroll loading of finding group resources.
|
||||
*
|
||||
* Uses refs for all mutable state to avoid dependency chains that
|
||||
* cause infinite re-render loops. Only `checkId` triggers a new
|
||||
* initial fetch via useEffect.
|
||||
*/
|
||||
export function useInfiniteResources({
|
||||
checkId,
|
||||
hasDateOrScanFilter,
|
||||
filters,
|
||||
onSetResources,
|
||||
onAppendResources,
|
||||
onSetLoading,
|
||||
}: UseInfiniteResourcesOptions): UseInfiniteResourcesReturn {
|
||||
// All mutable state in refs to break dependency chains
|
||||
const pageRef = useRef(1);
|
||||
const hasMoreRef = useRef(true);
|
||||
const isLoadingRef = useRef(false);
|
||||
const currentCheckIdRef = useRef(checkId);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
|
||||
// Store latest values in refs so the fetch function always reads current values
|
||||
// without being recreated on every render
|
||||
const hasDateOrScanRef = useRef(hasDateOrScanFilter);
|
||||
const filtersRef = useRef(filters);
|
||||
const onSetResourcesRef = useRef(onSetResources);
|
||||
const onAppendResourcesRef = useRef(onAppendResources);
|
||||
const onSetLoadingRef = useRef(onSetLoading);
|
||||
|
||||
// Keep refs in sync with latest props
|
||||
hasDateOrScanRef.current = hasDateOrScanFilter;
|
||||
filtersRef.current = filters;
|
||||
onSetResourcesRef.current = onSetResources;
|
||||
onAppendResourcesRef.current = onAppendResources;
|
||||
onSetLoadingRef.current = onSetLoading;
|
||||
|
||||
const fetchPage = useCallback(
|
||||
async (page: number, append: boolean, forCheckId: string) => {
|
||||
if (isLoadingRef.current) return;
|
||||
|
||||
isLoadingRef.current = true;
|
||||
onSetLoadingRef.current(true);
|
||||
|
||||
const fetchFn = hasDateOrScanRef.current
|
||||
? getFindingGroupResources
|
||||
: getLatestFindingGroupResources;
|
||||
|
||||
try {
|
||||
const response = await fetchFn({
|
||||
checkId: forCheckId,
|
||||
page,
|
||||
pageSize: RESOURCES_PAGE_SIZE,
|
||||
filters: filtersRef.current,
|
||||
});
|
||||
|
||||
// Discard stale response if checkId changed during fetch
|
||||
if (currentCheckIdRef.current !== forCheckId) return;
|
||||
|
||||
const resources = adaptFindingGroupResourcesResponse(
|
||||
response,
|
||||
forCheckId,
|
||||
);
|
||||
const totalPages = response?.meta?.pagination?.pages ?? 1;
|
||||
const hasMore = page < totalPages;
|
||||
|
||||
hasMoreRef.current = hasMore;
|
||||
|
||||
if (append) {
|
||||
onAppendResourcesRef.current(resources, hasMore);
|
||||
} else {
|
||||
onSetResourcesRef.current(resources, hasMore);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching resources:", error);
|
||||
if (currentCheckIdRef.current === forCheckId) {
|
||||
onSetLoadingRef.current(false);
|
||||
}
|
||||
} finally {
|
||||
isLoadingRef.current = false;
|
||||
}
|
||||
},
|
||||
[], // No dependencies — reads everything from refs
|
||||
);
|
||||
|
||||
// Fetch first page when checkId changes
|
||||
useEffect(() => {
|
||||
currentCheckIdRef.current = checkId;
|
||||
pageRef.current = 1;
|
||||
hasMoreRef.current = true;
|
||||
isLoadingRef.current = false;
|
||||
|
||||
fetchPage(1, false, checkId);
|
||||
}, [checkId, fetchPage]);
|
||||
|
||||
const loadNextPage = useCallback(() => {
|
||||
if (!hasMoreRef.current || isLoadingRef.current) return;
|
||||
|
||||
const nextPage = pageRef.current + 1;
|
||||
pageRef.current = nextPage;
|
||||
fetchPage(nextPage, true, currentCheckIdRef.current);
|
||||
}, [fetchPage]);
|
||||
|
||||
// IntersectionObserver callback — stable since loadNextPage is stable
|
||||
const handleIntersection = useCallback(
|
||||
(entries: IntersectionObserverEntry[]) => {
|
||||
const [entry] = entries;
|
||||
if (entry.isIntersecting) {
|
||||
loadNextPage();
|
||||
}
|
||||
},
|
||||
[loadNextPage],
|
||||
);
|
||||
|
||||
// Set up observer when sentinel node changes
|
||||
const sentinelRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
observerRef.current = null;
|
||||
}
|
||||
|
||||
if (node) {
|
||||
observerRef.current = new IntersectionObserver(handleIntersection, {
|
||||
rootMargin: "200px",
|
||||
});
|
||||
observerRef.current.observe(node);
|
||||
}
|
||||
},
|
||||
[handleIntersection],
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { sentinelRef };
|
||||
}
|
||||
89
ui/lib/region-flags.ts
Normal file
89
ui/lib/region-flags.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Maps cloud region strings to flag emojis.
|
||||
*
|
||||
* Supports AWS (us-east-1), Azure (eastus), GCP (us-central1),
|
||||
* and other providers with common region naming patterns.
|
||||
*/
|
||||
|
||||
const REGION_FLAG_RULES: [RegExp, string][] = [
|
||||
// United States
|
||||
[
|
||||
/\bus[-_]?|useast|uswest|usgov|uscentral|northamerica|virginia|ohio|oregon|california/i,
|
||||
"🇺🇸",
|
||||
],
|
||||
// European Union / general Europe
|
||||
[
|
||||
/\beu[-_]?|europe|euwest|eucentral|eunorth|eusouth|frankfurt|ireland|paris|stockholm|milan|spain|zurich/i,
|
||||
"🇪🇺",
|
||||
],
|
||||
// United Kingdom
|
||||
[/\buk[-_]?|uksouth|ukwest|london/i, "🇬🇧"],
|
||||
// Germany
|
||||
[/germany|germanycentral/i, "🇩🇪"],
|
||||
// France
|
||||
[/france|francecentral/i, "🇫🇷"],
|
||||
// Ireland
|
||||
[/\bireland\b/i, "🇮🇪"],
|
||||
// Sweden
|
||||
[/sweden/i, "🇸🇪"],
|
||||
// Switzerland
|
||||
[/switzerland|switz/i, "🇨🇭"],
|
||||
// Italy
|
||||
[/italy|italynorth/i, "🇮🇹"],
|
||||
// Spain
|
||||
[/\bspain\b/i, "🇪🇸"],
|
||||
// Norway
|
||||
[/norway/i, "🇳🇴"],
|
||||
// Poland
|
||||
[/poland/i, "🇵🇱"],
|
||||
// Canada
|
||||
[/\bca[-_]?|canada|canadacentral|canadaeast/i, "🇨🇦"],
|
||||
// Brazil
|
||||
[/\bsa[-_]?|brazil|southamerica|saeast|brazilsouth/i, "🇧🇷"],
|
||||
// Japan
|
||||
[/\bap[-_]?northeast[-_]?1|japan|japaneast|japanwest|tokyo|osaka/i, "🇯🇵"],
|
||||
// South Korea
|
||||
[/\bap[-_]?northeast[-_]?[23]|korea|koreacentral|koreasouth|seoul/i, "🇰🇷"],
|
||||
// Australia
|
||||
[
|
||||
/\bap[-_]?southeast[-_]?2|australia|australiaeast|australiacentral|sydney|melbourne/i,
|
||||
"🇦🇺",
|
||||
],
|
||||
// Singapore
|
||||
[/\bap[-_]?southeast[-_]?1|singapore/i, "🇸🇬"],
|
||||
// India
|
||||
[
|
||||
/\bap[-_]?south[-_]?1|india|centralindia|southindia|westindia|mumbai|hyderabad/i,
|
||||
"🇮🇳",
|
||||
],
|
||||
// Hong Kong
|
||||
[/\bap[-_]?east[-_]?1|hongkong/i, "🇭🇰"],
|
||||
// Indonesia
|
||||
[/\bap[-_]?southeast[-_]?3|indonesia|jakarta/i, "🇮🇩"],
|
||||
// China
|
||||
[/\bcn[-_]?|china|chinaeast|chinanorth|beijing|shanghai|ningxia/i, "🇨🇳"],
|
||||
// Middle East / UAE
|
||||
[/\bme[-_]?|middleeast|uaecentral|uaenorth|dubai|bahrain/i, "🇦🇪"],
|
||||
// Israel
|
||||
[/israel|israelcentral/i, "🇮🇱"],
|
||||
// South Africa
|
||||
[/\baf[-_]?|africa|southafrica|capetown|johannesburg/i, "🇿🇦"],
|
||||
// Asia Pacific (generic fallback)
|
||||
[/\bap[-_]?|asia/i, "🌏"],
|
||||
// Global / multi-region
|
||||
[/global|multi/i, "🌐"],
|
||||
];
|
||||
|
||||
export function getRegionFlag(region: string): string {
|
||||
if (!region || region === "-") return "";
|
||||
|
||||
const normalized = region.toLowerCase().replace(/\s+/g, "");
|
||||
|
||||
for (const [pattern, flag] of REGION_FLAG_RULES) {
|
||||
if (pattern.test(normalized)) {
|
||||
return flag;
|
||||
}
|
||||
}
|
||||
|
||||
return "🌐";
|
||||
}
|
||||
58
ui/store/findings-drilldown.ts
Normal file
58
ui/store/findings-drilldown.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
import { FindingGroupRow, FindingResourceRow } from "@/types";
|
||||
|
||||
interface FindingsDrillDownState {
|
||||
expandedCheckId: string | null;
|
||||
expandedGroup: FindingGroupRow | null;
|
||||
resources: FindingResourceRow[];
|
||||
resourcesPage: number;
|
||||
hasMoreResources: boolean;
|
||||
isLoadingResources: boolean;
|
||||
drillDown: (checkId: string, group: FindingGroupRow) => void;
|
||||
collapse: () => void;
|
||||
setResources: (resources: FindingResourceRow[], hasMore: boolean) => void;
|
||||
appendResources: (resources: FindingResourceRow[], hasMore: boolean) => void;
|
||||
setLoadingResources: (loading: boolean) => void;
|
||||
incrementPage: () => void;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
expandedCheckId: null,
|
||||
expandedGroup: null,
|
||||
resources: [],
|
||||
resourcesPage: 1,
|
||||
hasMoreResources: false,
|
||||
isLoadingResources: false,
|
||||
};
|
||||
|
||||
export const useFindingsDrillDownStore = create<FindingsDrillDownState>()(
|
||||
(set) => ({
|
||||
...initialState,
|
||||
drillDown: (checkId, group) =>
|
||||
set({
|
||||
expandedCheckId: checkId,
|
||||
expandedGroup: group,
|
||||
resources: [],
|
||||
resourcesPage: 1,
|
||||
hasMoreResources: false,
|
||||
isLoadingResources: true,
|
||||
}),
|
||||
collapse: () => set(initialState),
|
||||
setResources: (resources, hasMore) =>
|
||||
set({
|
||||
resources,
|
||||
hasMoreResources: hasMore,
|
||||
isLoadingResources: false,
|
||||
}),
|
||||
appendResources: (newResources, hasMore) =>
|
||||
set((state) => ({
|
||||
resources: [...state.resources, ...newResources],
|
||||
hasMoreResources: hasMore,
|
||||
isLoadingResources: false,
|
||||
})),
|
||||
setLoadingResources: (loading) => set({ isLoadingResources: loading }),
|
||||
incrementPage: () =>
|
||||
set((state) => ({ resourcesPage: state.resourcesPage + 1 })),
|
||||
}),
|
||||
);
|
||||
60
ui/types/findings-table.ts
Normal file
60
ui/types/findings-table.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { FindingStatus, Severity } from "./components";
|
||||
import { ProviderType } from "./providers";
|
||||
|
||||
export const FINDINGS_ROW_TYPE = {
|
||||
GROUP: "group",
|
||||
RESOURCE: "resource",
|
||||
} as const;
|
||||
|
||||
export type FindingsRowType =
|
||||
(typeof FINDINGS_ROW_TYPE)[keyof typeof FINDINGS_ROW_TYPE];
|
||||
|
||||
export interface FindingGroupRow {
|
||||
id: string;
|
||||
rowType: typeof FINDINGS_ROW_TYPE.GROUP;
|
||||
checkId: string;
|
||||
checkTitle: string;
|
||||
severity: Severity;
|
||||
status: FindingStatus;
|
||||
resourcesTotal: number;
|
||||
resourcesFail: number;
|
||||
newCount: number;
|
||||
changedCount: number;
|
||||
mutedCount: number;
|
||||
providers: ProviderType[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface FindingResourceRow {
|
||||
id: string;
|
||||
rowType: typeof FINDINGS_ROW_TYPE.RESOURCE;
|
||||
findingId: string;
|
||||
checkId: string;
|
||||
providerType: ProviderType;
|
||||
providerAlias: string;
|
||||
providerUid: string;
|
||||
resourceName: string;
|
||||
resourceUid: string;
|
||||
service: string;
|
||||
region: string;
|
||||
severity: Severity;
|
||||
status: string;
|
||||
isMuted: boolean;
|
||||
mutedReason?: string;
|
||||
firstSeenAt: string | null;
|
||||
lastSeenAt: string | null;
|
||||
}
|
||||
|
||||
export type FindingsTableRow = FindingGroupRow | FindingResourceRow;
|
||||
|
||||
export function isFindingGroupRow(
|
||||
row: FindingsTableRow,
|
||||
): row is FindingGroupRow {
|
||||
return row.rowType === FINDINGS_ROW_TYPE.GROUP;
|
||||
}
|
||||
|
||||
export function isFindingResourceRow(
|
||||
row: FindingsTableRow,
|
||||
): row is FindingResourceRow {
|
||||
return row.rowType === FINDINGS_ROW_TYPE.RESOURCE;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from "./authFormSchema";
|
||||
export * from "./components";
|
||||
export * from "./filters";
|
||||
export * from "./findings-table";
|
||||
export * from "./formSchemas";
|
||||
export * from "./organizations";
|
||||
export * from "./processors";
|
||||
|
||||
Reference in New Issue
Block a user