mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
feat(ui): add findings grouped view (#10425)
Co-authored-by: Adrián Jesús Peña Rodríguez <adrianjpr@gmail.com> Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
This commit is contained in:
co-authored by
Adrián Jesús Peña Rodríguez
Alan Buscaglia
parent
3b875484b0
commit
50556df713
@@ -4,6 +4,10 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
## [1.23.0] (Prowler UNRELEASED)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Findings grouped view with drill-down table showing resources per check, resource detail drawer, infinite scroll pagination, and bulk mute support [(#10425)](https://github.com/prowler-cloud/prowler/pull/10425)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Attack Paths custom openCypher queries now use a code editor with syntax highlighting and line numbers [(#10445)](https://github.com/prowler-cloud/prowler/pull/10445)
|
||||
@@ -31,6 +35,14 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Attack Paths custom openCypher queries with Cartography schema guidance and clearer execution errors [(#10397)](https://github.com/prowler-cloud/prowler/pull/10397)
|
||||
|
||||
---
|
||||
|
||||
## [1.21.0] (Prowler v5.21.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Skill system to Lighthouse AI [(#10322)](https://github.com/prowler-cloud/prowler/pull/10322)
|
||||
- Skill for creating custom queries on Attack Paths [(#10323)](https://github.com/prowler-cloud/prowler/pull/10323)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { formatDuration } from "@/lib/date-utils";
|
||||
import { MetaDataProps } from "@/types";
|
||||
import { AttackPathScan, AttackPathScansResponse } from "@/types/attack-paths";
|
||||
|
||||
@@ -61,18 +62,6 @@ export function adaptAttackPathScansResponse(
|
||||
return { data: enrichedData, metadata };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in seconds to human-readable format
|
||||
*
|
||||
* @param seconds - Duration in seconds
|
||||
* @returns Formatted duration string (e.g., "2m 30s")
|
||||
*/
|
||||
function formatDuration(seconds: number): string {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a scan is recent (completed within last 24 hours)
|
||||
*
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
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 FindingGroupAttributes {
|
||||
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;
|
||||
}
|
||||
|
||||
interface FindingGroupApiItem {
|
||||
type: "finding-groups";
|
||||
id: string;
|
||||
attributes: FindingGroupAttributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ResourceInfo {
|
||||
uid: string;
|
||||
name: string;
|
||||
service: string;
|
||||
region: string;
|
||||
type: string;
|
||||
resource_group: string;
|
||||
}
|
||||
|
||||
interface ProviderInfo {
|
||||
type: string;
|
||||
uid: string;
|
||||
alias: string;
|
||||
}
|
||||
|
||||
interface FindingGroupResourceAttributes {
|
||||
resource: ResourceInfo;
|
||||
provider: ProviderInfo;
|
||||
status: string;
|
||||
severity: string;
|
||||
first_seen_at: string | null;
|
||||
last_seen_at: string | null;
|
||||
muted_reason?: string | null;
|
||||
}
|
||||
|
||||
interface FindingGroupResourceApiItem {
|
||||
type: "finding-group-resources";
|
||||
id: string;
|
||||
attributes: FindingGroupResourceAttributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 || "-",
|
||||
resourceGroup: item.attributes.resource?.resource_group || "-",
|
||||
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",
|
||||
// TODO: remove fallback once the API returns muted_reason in finding-group-resources
|
||||
mutedReason: item.attributes.muted_reason || undefined,
|
||||
firstSeenAt: item.attributes.first_seen_at,
|
||||
lastSeenAt: item.attributes.last_seen_at,
|
||||
}));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./finding-groups";
|
||||
export * from "./finding-groups.adapter";
|
||||
@@ -0,0 +1,224 @@
|
||||
import { createDict } from "@/lib";
|
||||
import { ProviderType, Severity } from "@/types";
|
||||
|
||||
export interface RemediationRecommendation {
|
||||
text: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface RemediationCode {
|
||||
cli: string;
|
||||
other: string;
|
||||
nativeiac: string;
|
||||
terraform: string;
|
||||
}
|
||||
|
||||
export interface Remediation {
|
||||
recommendation: RemediationRecommendation;
|
||||
code: RemediationCode;
|
||||
}
|
||||
|
||||
export interface ScanInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
trigger: string;
|
||||
state: string;
|
||||
uniqueResourceCount: number;
|
||||
progress: number;
|
||||
duration: number;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
insertedAt: string | null;
|
||||
scheduledAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
resourceId: string;
|
||||
resourceUid: string;
|
||||
resourceName: string;
|
||||
resourceService: string;
|
||||
resourceRegion: string;
|
||||
resourceType: string;
|
||||
resourceGroup: string;
|
||||
// Provider
|
||||
providerType: ProviderType;
|
||||
providerAlias: string;
|
||||
providerUid: string;
|
||||
// Check metadata (flattened)
|
||||
risk: string;
|
||||
description: string;
|
||||
statusExtended: string;
|
||||
complianceFrameworks: string[];
|
||||
categories: string[];
|
||||
remediation: Remediation;
|
||||
additionalUrls: string[];
|
||||
// Scan
|
||||
scan: ScanInfo | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts unique compliance framework names from available data.
|
||||
*
|
||||
* Supports three shapes:
|
||||
* 1a. check_metadata.compliance — array of { Framework, Version, ... } objects
|
||||
* e.g. [{ Framework: "CIS-AWS", Version: "1.4" }, { Framework: "PCI-DSS" }]
|
||||
* 1b. check_metadata.compliance — dict with framework keys and control arrays
|
||||
* e.g. {"CIS-1.4": ["1.6"], "GDPR": ["article_25"], "HIPAA": ["164_312_d"]}
|
||||
* 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 1a: 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 1b: check_metadata.compliance — dict keyed by framework name
|
||||
else if (metaCompliance && typeof metaCompliance === "object") {
|
||||
for (const key of Object.keys(metaCompliance as Record<string, unknown>)) {
|
||||
const base = key.replace(/-\d+(\.\d+)*$/, "");
|
||||
frameworks.add(base);
|
||||
}
|
||||
}
|
||||
|
||||
// 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).sort((a, b) =>
|
||||
a.localeCompare(b, undefined, { sensitivity: "base" }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
resourceId: resourceRel?.id || "",
|
||||
resourceUid: resourceAttrs.uid || "-",
|
||||
resourceName: resourceAttrs.name || "-",
|
||||
resourceService: resourceAttrs.service || "-",
|
||||
resourceRegion: resourceAttrs.region || "-",
|
||||
resourceType: resourceAttrs.type || "-",
|
||||
resourceGroup: meta.resourcegroup || "-",
|
||||
// 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 || [],
|
||||
// Scan
|
||||
scan: scan?.attributes
|
||||
? {
|
||||
id: scan.id || "",
|
||||
name: scan.attributes.name || "",
|
||||
trigger: scan.attributes.trigger || "",
|
||||
state: scan.attributes.state || "",
|
||||
uniqueResourceCount: scan.attributes.unique_resource_count || 0,
|
||||
progress: scan.attributes.progress || 0,
|
||||
duration: scan.attributes.duration || 0,
|
||||
startedAt: scan.attributes.started_at || null,
|
||||
completedAt: scan.attributes.completed_at || null,
|
||||
insertedAt: scan.attributes.inserted_at || null,
|
||||
scheduledAt: scan.attributes.scheduled_at || null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
fetchMock,
|
||||
getAuthHeadersMock,
|
||||
handleApiResponseMock,
|
||||
appendSanitizedProviderTypeFiltersMock,
|
||||
getFindingGroupResourcesMock,
|
||||
getLatestFindingGroupResourcesMock,
|
||||
} = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
getAuthHeadersMock: vi.fn(),
|
||||
handleApiResponseMock: vi.fn(),
|
||||
appendSanitizedProviderTypeFiltersMock: vi.fn(
|
||||
(url: URL, filters: Record<string, string>) => {
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (key !== "filter[search]") {
|
||||
url.searchParams.append(key, value);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
getFindingGroupResourcesMock: vi.fn(),
|
||||
getLatestFindingGroupResourcesMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/provider-filters", () => ({
|
||||
appendSanitizedProviderTypeFilters: appendSanitizedProviderTypeFiltersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/finding-groups", () => ({
|
||||
getFindingGroupResources: getFindingGroupResourcesMock,
|
||||
getLatestFindingGroupResources: getLatestFindingGroupResourcesMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
resolveFindingIds,
|
||||
resolveFindingIdsByCheckIds,
|
||||
resolveFindingIdsByVisibleGroupResources,
|
||||
} from "./findings-by-resource";
|
||||
|
||||
describe("resolveFindingIdsByCheckIds", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
});
|
||||
|
||||
it("should resolve all finding IDs across every page for the latest endpoint", async () => {
|
||||
// Given
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(new Response("", { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response("", { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response("", { status: 200 }));
|
||||
|
||||
handleApiResponseMock
|
||||
.mockResolvedValueOnce({
|
||||
data: [{ id: "finding-1" }, { id: "finding-2" }],
|
||||
meta: { pagination: { pages: 3 } },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: [{ id: "finding-3" }],
|
||||
meta: { pagination: { pages: 3 } },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: [{ id: "finding-4" }],
|
||||
meta: { pagination: { pages: 3 } },
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await resolveFindingIdsByCheckIds({
|
||||
checkIds: ["check-1", "check-2"],
|
||||
filters: {
|
||||
"filter[provider_type__in]": "aws",
|
||||
"filter[search]": "ignored-search",
|
||||
},
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([
|
||||
"finding-1",
|
||||
"finding-2",
|
||||
"finding-3",
|
||||
"finding-4",
|
||||
]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
|
||||
const firstCallUrl = new URL(fetchMock.mock.calls[0][0]);
|
||||
expect(firstCallUrl.pathname).toBe("/api/v1/findings/latest");
|
||||
expect(firstCallUrl.searchParams.get("filter[check_id__in]")).toBe(
|
||||
"check-1,check-2",
|
||||
);
|
||||
expect(firstCallUrl.searchParams.get("filter[muted]")).toBe("false");
|
||||
expect(firstCallUrl.searchParams.get("page[size]")).toBe("500");
|
||||
expect(firstCallUrl.searchParams.get("page[number]")).toBe("1");
|
||||
expect(firstCallUrl.searchParams.get("fields[findings]")).toBe("uid");
|
||||
expect(firstCallUrl.searchParams.get("filter[provider_type__in]")).toBe(
|
||||
"aws",
|
||||
);
|
||||
expect(firstCallUrl.searchParams.get("filter[search]")).toBeNull();
|
||||
|
||||
const laterPages = fetchMock.mock.calls
|
||||
.slice(1)
|
||||
.map(([url]) => new URL(url).searchParams.get("page[number]"));
|
||||
expect(laterPages.sort()).toEqual(["2", "3"]);
|
||||
});
|
||||
|
||||
it("should use the dated findings endpoint when date or scan filters are active", async () => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
data: [{ id: "finding-1" }],
|
||||
meta: { pagination: { pages: 1 } },
|
||||
});
|
||||
|
||||
// When
|
||||
await resolveFindingIdsByCheckIds({
|
||||
checkIds: ["check-1"],
|
||||
hasDateOrScanFilter: true,
|
||||
filters: {
|
||||
"filter[scan__in]": "scan-1",
|
||||
"filter[inserted_at__gte]": "2026-03-01",
|
||||
},
|
||||
});
|
||||
|
||||
// Then
|
||||
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
||||
expect(calledUrl.pathname).toBe("/api/v1/findings");
|
||||
expect(calledUrl.searchParams.get("filter[scan__in]")).toBe("scan-1");
|
||||
expect(calledUrl.searchParams.get("filter[inserted_at__gte]")).toBe(
|
||||
"2026-03-01",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveFindingIds", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
});
|
||||
|
||||
it("should use the dated findings endpoint when date or scan filters are active", async () => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
data: [{ id: "finding-1" }, { id: "finding-2" }],
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await resolveFindingIds({
|
||||
checkId: "check-1",
|
||||
resourceUids: ["resource-1", "resource-2"],
|
||||
hasDateOrScanFilter: true,
|
||||
filters: {
|
||||
"filter[scan__in]": "scan-1",
|
||||
"filter[inserted_at__gte]": "2026-03-01",
|
||||
},
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result).toEqual(["finding-1", "finding-2"]);
|
||||
|
||||
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
||||
expect(calledUrl.pathname).toBe("/api/v1/findings");
|
||||
expect(calledUrl.searchParams.get("filter[check_id]")).toBe("check-1");
|
||||
expect(calledUrl.searchParams.get("filter[resource_uid__in]")).toBe(
|
||||
"resource-1,resource-2",
|
||||
);
|
||||
expect(calledUrl.searchParams.get("filter[scan__in]")).toBe("scan-1");
|
||||
expect(calledUrl.searchParams.get("filter[inserted_at__gte]")).toBe(
|
||||
"2026-03-01",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveFindingIdsByVisibleGroupResources", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
});
|
||||
|
||||
it("should resolve finding IDs from the group's visible resource UIDs instead of muting the whole check", async () => {
|
||||
// Given
|
||||
getLatestFindingGroupResourcesMock
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
id: "resource-row-1",
|
||||
attributes: {
|
||||
resource: { uid: "resource-1" },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "resource-row-2",
|
||||
attributes: {
|
||||
resource: { uid: "resource-2" },
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: { pagination: { pages: 2 } },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
id: "resource-row-3",
|
||||
attributes: {
|
||||
resource: { uid: "resource-3" },
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: { pagination: { pages: 2 } },
|
||||
});
|
||||
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
data: [{ id: "finding-1" }, { id: "finding-2" }, { id: "finding-3" }],
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await resolveFindingIdsByVisibleGroupResources({
|
||||
checkId: "check-1",
|
||||
filters: {
|
||||
"filter[provider_type__in]": "aws",
|
||||
},
|
||||
resourceSearch: "visible subset",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result).toEqual(["finding-1", "finding-2", "finding-3"]);
|
||||
expect(getLatestFindingGroupResourcesMock).toHaveBeenCalledTimes(2);
|
||||
expect(getLatestFindingGroupResourcesMock).toHaveBeenNthCalledWith(1, {
|
||||
checkId: "check-1",
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
filters: {
|
||||
"filter[provider_type__in]": "aws",
|
||||
"filter[name__icontains]": "visible subset",
|
||||
},
|
||||
});
|
||||
expect(getLatestFindingGroupResourcesMock).toHaveBeenNthCalledWith(2, {
|
||||
checkId: "check-1",
|
||||
page: 2,
|
||||
pageSize: 500,
|
||||
filters: {
|
||||
"filter[provider_type__in]": "aws",
|
||||
"filter[name__icontains]": "visible subset",
|
||||
},
|
||||
});
|
||||
|
||||
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
||||
expect(calledUrl.pathname).toBe("/api/v1/findings/latest");
|
||||
expect(calledUrl.searchParams.get("filter[check_id]")).toBe("check-1");
|
||||
expect(calledUrl.searchParams.get("filter[check_id__in]")).toBeNull();
|
||||
expect(calledUrl.searchParams.get("filter[resource_uid__in]")).toBe(
|
||||
"resource-1,resource-2,resource-3",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,385 @@
|
||||
"use server";
|
||||
|
||||
import {
|
||||
getFindingGroupResources,
|
||||
getLatestFindingGroupResources,
|
||||
} from "@/actions/finding-groups";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { runWithConcurrencyLimit } from "@/lib/concurrency";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
const FINDING_IDS_RESOLUTION_PAGE_SIZE = 500;
|
||||
const FINDING_IDS_RESOLUTION_CONCURRENCY = 4;
|
||||
const FINDING_GROUP_RESOURCES_RESOLUTION_PAGE_SIZE = 500;
|
||||
const FINDING_FIELDS = "uid";
|
||||
|
||||
interface ResolveFindingIdsByCheckIdsParams {
|
||||
checkIds: string[];
|
||||
filters?: Record<string, string>;
|
||||
hasDateOrScanFilter?: boolean;
|
||||
}
|
||||
|
||||
interface ResolveFindingIdsParams {
|
||||
checkId: string;
|
||||
resourceUids: string[];
|
||||
filters?: Record<string, string>;
|
||||
hasDateOrScanFilter?: boolean;
|
||||
}
|
||||
|
||||
interface ResolveFindingIdsByVisibleGroupResourcesParams {
|
||||
checkId: string;
|
||||
filters?: Record<string, string>;
|
||||
hasDateOrScanFilter?: boolean;
|
||||
resourceSearch?: string;
|
||||
}
|
||||
|
||||
interface FindingIdsPageResponse {
|
||||
ids: string[];
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
interface FindingGroupResourceUidsPageResponse {
|
||||
resourceUids: string[];
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
function createFindingsResolutionUrl({
|
||||
checkIds,
|
||||
filters = {},
|
||||
page,
|
||||
hasDateOrScanFilter = false,
|
||||
}: ResolveFindingIdsByCheckIdsParams & {
|
||||
page: number;
|
||||
}): URL {
|
||||
const endpoint = hasDateOrScanFilter ? "findings" : "findings/latest";
|
||||
const url = new URL(`${apiBaseUrl}/${endpoint}`);
|
||||
|
||||
url.searchParams.append("filter[check_id__in]", checkIds.join(","));
|
||||
url.searchParams.append("filter[muted]", "false");
|
||||
url.searchParams.append("fields[findings]", FINDING_FIELDS);
|
||||
url.searchParams.append("page[number]", page.toString());
|
||||
url.searchParams.append(
|
||||
"page[size]",
|
||||
FINDING_IDS_RESOLUTION_PAGE_SIZE.toString(),
|
||||
);
|
||||
|
||||
appendSanitizedProviderTypeFilters(url, filters);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
async function fetchFindingIdsPage({
|
||||
headers,
|
||||
page,
|
||||
...params
|
||||
}: ResolveFindingIdsByCheckIdsParams & {
|
||||
headers: HeadersInit;
|
||||
page: number;
|
||||
}): Promise<FindingIdsPageResponse> {
|
||||
const response = await fetch(
|
||||
createFindingsResolutionUrl({ ...params, page }).toString(),
|
||||
{
|
||||
headers,
|
||||
},
|
||||
);
|
||||
const data = await handleApiResponse(response);
|
||||
|
||||
if (!data?.data || !Array.isArray(data.data)) {
|
||||
return { ids: [], totalPages: 1 };
|
||||
}
|
||||
|
||||
return {
|
||||
ids: data.data
|
||||
.map((item: { id?: string }) => item.id)
|
||||
.filter((id: string | undefined): id is string => Boolean(id)),
|
||||
totalPages: data?.meta?.pagination?.pages ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
function chunkValues<T>(values: T[], chunkSize: number): T[][] {
|
||||
const chunks: T[][] = [];
|
||||
for (let index = 0; index < values.length; index += chunkSize) {
|
||||
chunks.push(values.slice(index, index + chunkSize));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function createResourceFindingResolutionUrl({
|
||||
checkId,
|
||||
resourceUids,
|
||||
filters = {},
|
||||
hasDateOrScanFilter = false,
|
||||
}: ResolveFindingIdsParams): URL {
|
||||
const endpoint = hasDateOrScanFilter ? "findings" : "findings/latest";
|
||||
const url = new URL(`${apiBaseUrl}/${endpoint}`);
|
||||
|
||||
url.searchParams.append("filter[check_id]", checkId);
|
||||
url.searchParams.append("filter[resource_uid__in]", resourceUids.join(","));
|
||||
url.searchParams.append("filter[muted]", "false");
|
||||
url.searchParams.append("page[size]", resourceUids.length.toString());
|
||||
|
||||
appendSanitizedProviderTypeFilters(url, filters);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
async function fetchFindingIdsForResourceUids({
|
||||
headers,
|
||||
...params
|
||||
}: ResolveFindingIdsParams & {
|
||||
headers: HeadersInit;
|
||||
}): Promise<string[]> {
|
||||
const response = await fetch(
|
||||
createResourceFindingResolutionUrl(params).toString(),
|
||||
{
|
||||
headers,
|
||||
},
|
||||
);
|
||||
const data = await handleApiResponse(response);
|
||||
|
||||
if (!data?.data || !Array.isArray(data.data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.data
|
||||
.map((item: { id?: string }) => item.id)
|
||||
.filter((id: string | undefined): id is string => Boolean(id));
|
||||
}
|
||||
|
||||
function buildFindingGroupResourceFilters({
|
||||
filters = {},
|
||||
resourceSearch,
|
||||
}: Pick<
|
||||
ResolveFindingIdsByVisibleGroupResourcesParams,
|
||||
"filters" | "resourceSearch"
|
||||
>): Record<string, string> {
|
||||
const nextFilters = { ...filters };
|
||||
if (resourceSearch) {
|
||||
nextFilters["filter[name__icontains]"] = resourceSearch;
|
||||
}
|
||||
return nextFilters;
|
||||
}
|
||||
|
||||
async function fetchFindingGroupResourceUidsPage({
|
||||
checkId,
|
||||
filters = {},
|
||||
hasDateOrScanFilter = false,
|
||||
page,
|
||||
resourceSearch,
|
||||
}: ResolveFindingIdsByVisibleGroupResourcesParams & {
|
||||
page: number;
|
||||
}): Promise<FindingGroupResourceUidsPageResponse> {
|
||||
const fetchFn = hasDateOrScanFilter
|
||||
? getFindingGroupResources
|
||||
: getLatestFindingGroupResources;
|
||||
|
||||
const response = await fetchFn({
|
||||
checkId,
|
||||
page,
|
||||
pageSize: FINDING_GROUP_RESOURCES_RESOLUTION_PAGE_SIZE,
|
||||
filters: buildFindingGroupResourceFilters({ filters, resourceSearch }),
|
||||
});
|
||||
|
||||
const data = response?.data;
|
||||
|
||||
if (!data || !Array.isArray(data)) {
|
||||
return { resourceUids: [], totalPages: 1 };
|
||||
}
|
||||
|
||||
return {
|
||||
resourceUids: data
|
||||
.map(
|
||||
(item: { attributes?: { resource?: { uid?: string } } }) =>
|
||||
item.attributes?.resource?.uid,
|
||||
)
|
||||
.filter((uid: string | undefined): uid is string => Boolean(uid)),
|
||||
totalPages: response?.meta?.pagination?.pages ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves resource UIDs + check ID into actual finding UUIDs.
|
||||
* Uses /findings/latest (or /findings when date/scan filters are active)
|
||||
* with check_id and resource_uid__in filters to batch-resolve actual finding IDs.
|
||||
*/
|
||||
export const resolveFindingIds = async ({
|
||||
checkId,
|
||||
resourceUids,
|
||||
filters = {},
|
||||
hasDateOrScanFilter = false,
|
||||
}: ResolveFindingIdsParams): Promise<string[]> => {
|
||||
if (resourceUids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const resourceUidChunks = chunkValues(
|
||||
Array.from(new Set(resourceUids)),
|
||||
FINDING_IDS_RESOLUTION_PAGE_SIZE,
|
||||
);
|
||||
|
||||
try {
|
||||
const results = await runWithConcurrencyLimit(
|
||||
resourceUidChunks,
|
||||
FINDING_IDS_RESOLUTION_CONCURRENCY,
|
||||
(resourceUidChunk) =>
|
||||
fetchFindingIdsForResourceUids({
|
||||
checkId,
|
||||
resourceUids: resourceUidChunk,
|
||||
filters,
|
||||
hasDateOrScanFilter,
|
||||
headers,
|
||||
}),
|
||||
);
|
||||
|
||||
return Array.from(new Set(results.flat()));
|
||||
} 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,
|
||||
filters = {},
|
||||
hasDateOrScanFilter = false,
|
||||
}: ResolveFindingIdsByCheckIdsParams): Promise<string[]> => {
|
||||
if (checkIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
try {
|
||||
const firstPage = await fetchFindingIdsPage({
|
||||
checkIds,
|
||||
filters,
|
||||
hasDateOrScanFilter,
|
||||
headers,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
const remainingPages = Array.from(
|
||||
{ length: Math.max(0, firstPage.totalPages - 1) },
|
||||
(_, index) => index + 2,
|
||||
);
|
||||
|
||||
const remainingResults = await runWithConcurrencyLimit(
|
||||
remainingPages,
|
||||
FINDING_IDS_RESOLUTION_CONCURRENCY,
|
||||
async (page) =>
|
||||
fetchFindingIdsPage({
|
||||
checkIds,
|
||||
filters,
|
||||
hasDateOrScanFilter,
|
||||
headers,
|
||||
page,
|
||||
}),
|
||||
);
|
||||
|
||||
return Array.from(
|
||||
new Set([
|
||||
...firstPage.ids,
|
||||
...remainingResults.flatMap((result) => result.ids),
|
||||
]),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error resolving finding IDs by check IDs:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves a finding-group row to the actual findings for the resources
|
||||
* currently visible in that group.
|
||||
*/
|
||||
export const resolveFindingIdsByVisibleGroupResources = async ({
|
||||
checkId,
|
||||
filters = {},
|
||||
hasDateOrScanFilter = false,
|
||||
resourceSearch,
|
||||
}: ResolveFindingIdsByVisibleGroupResourcesParams): Promise<string[]> => {
|
||||
try {
|
||||
const firstPage = await fetchFindingGroupResourceUidsPage({
|
||||
checkId,
|
||||
filters,
|
||||
hasDateOrScanFilter,
|
||||
page: 1,
|
||||
resourceSearch,
|
||||
});
|
||||
|
||||
const remainingPages = Array.from(
|
||||
{ length: Math.max(0, firstPage.totalPages - 1) },
|
||||
(_, index) => index + 2,
|
||||
);
|
||||
|
||||
const remainingResults = await runWithConcurrencyLimit(
|
||||
remainingPages,
|
||||
FINDING_IDS_RESOLUTION_CONCURRENCY,
|
||||
(page) =>
|
||||
fetchFindingGroupResourceUidsPage({
|
||||
checkId,
|
||||
filters,
|
||||
hasDateOrScanFilter,
|
||||
page,
|
||||
resourceSearch,
|
||||
}),
|
||||
);
|
||||
|
||||
const resourceUids = Array.from(
|
||||
new Set([
|
||||
...firstPage.resourceUids,
|
||||
...remainingResults.flatMap((result) => result.resourceUids),
|
||||
]),
|
||||
);
|
||||
|
||||
return resolveFindingIds({
|
||||
checkId,
|
||||
resourceUids,
|
||||
filters,
|
||||
hasDateOrScanFilter,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Error resolving finding IDs from visible group resources:",
|
||||
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";
|
||||
|
||||
@@ -182,6 +182,9 @@ export const createMuteRule = async (
|
||||
}
|
||||
};
|
||||
|
||||
// Note: Adding findings to existing mute rules is not supported by the API.
|
||||
// The MuteRuleUpdateSerializer only allows updating name, reason, and enabled fields.
|
||||
// finding_ids can only be specified when creating a new mute rule.
|
||||
export const updateMuteRule = async (
|
||||
_prevState: MuteRuleActionState,
|
||||
formData: FormData,
|
||||
@@ -374,10 +377,6 @@ export const deleteMuteRule = async (
|
||||
}
|
||||
};
|
||||
|
||||
// Note: Adding findings to existing mute rules is not supported by the API.
|
||||
// The MuteRuleUpdateSerializer only allows updating name, reason, and enabled fields.
|
||||
// finding_ids can only be specified when creating a new mute rule.
|
||||
|
||||
// Note: Unmute functionality is not currently supported by the API.
|
||||
// The FindingViewSet only allows GET operations, and deleting a mute rule
|
||||
// does not unmute the findings ("Previously muted findings remain muted").
|
||||
|
||||
@@ -222,7 +222,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;
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
|
||||
/**
|
||||
* Loading skeleton for graph visualization
|
||||
@@ -12,7 +12,7 @@ export const GraphLoading = () => {
|
||||
data-testid="graph-loading"
|
||||
className="flex min-h-[320px] flex-col items-center justify-center gap-4 text-center"
|
||||
>
|
||||
<TreeSpinner className="size-6" />
|
||||
<Spinner className="size-6" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Loading Attack Paths graph...
|
||||
</p>
|
||||
|
||||
+4
-4
@@ -7,6 +7,7 @@ import { Button } from "@/components/shadcn/button/button";
|
||||
import { DateWithTime } from "@/components/ui/entities/date-with-time";
|
||||
import { EntityInfo } from "@/components/ui/entities/entity-info";
|
||||
import { DataTable, DataTableColumnHeader } from "@/components/ui/table";
|
||||
import { formatDuration } from "@/lib/date-utils";
|
||||
import type { MetaDataProps, ProviderType } from "@/types";
|
||||
import type { AttackPathScan, ScanState } from "@/types/attack-paths";
|
||||
import { SCAN_STATES } from "@/types/attack-paths";
|
||||
@@ -32,10 +33,9 @@ const parsePageParam = (value: string | null, fallback: number) => {
|
||||
return Number.isNaN(parsedValue) || parsedValue < 1 ? fallback : parsedValue;
|
||||
};
|
||||
|
||||
const formatDuration = (duration: number | null) => {
|
||||
const formatNullableDuration = (duration: number | null) => {
|
||||
if (!duration) return "-";
|
||||
|
||||
return `${Math.floor(duration / 60)}m ${duration % 60}s`;
|
||||
return formatDuration(duration);
|
||||
};
|
||||
|
||||
const isSelectDisabled = (
|
||||
@@ -164,7 +164,7 @@ const getColumns = ({
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm">
|
||||
{formatDuration(row.original.attributes.duration)}
|
||||
{formatNullableDuration(row.original.attributes.duration)}
|
||||
</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import {
|
||||
getFindingById,
|
||||
getFindings,
|
||||
getLatestFindings,
|
||||
getLatestMetadataInfo,
|
||||
getMetadataInfo,
|
||||
} from "@/actions/findings";
|
||||
adaptFindingGroupsResponse,
|
||||
getFindingGroups,
|
||||
getLatestFindingGroups,
|
||||
} from "@/actions/finding-groups";
|
||||
import { getLatestMetadataInfo, getMetadataInfo } from "@/actions/findings";
|
||||
import { getProviders } from "@/actions/providers";
|
||||
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,
|
||||
hasDateOrScanFilter,
|
||||
} from "@/lib";
|
||||
import { ScanEntity, ScanProps } from "@/types";
|
||||
import { FindingProps, SearchParamsProps } from "@/types/components";
|
||||
import { SearchParamsProps } from "@/types/components";
|
||||
|
||||
export default async function Findings({
|
||||
searchParams,
|
||||
@@ -39,78 +36,18 @@ export default async function Findings({
|
||||
// Check if the searchParams contain any date or scan filter
|
||||
const hasDateOrScan = hasDateOrScanFilter(resolvedSearchParams);
|
||||
|
||||
// Check if there's a specific finding ID to fetch
|
||||
const findingId = resolvedSearchParams.id?.toString();
|
||||
// TODO: Re-implement deep link support (/findings?id=<uuid>) using the grouped view's resource detail drawer
|
||||
// once the legacy FindingDetailsSheet is fully deprecated (still used by /resources and overview dashboard).
|
||||
|
||||
const [metadataInfoData, providersData, scansData, findingByIdData] =
|
||||
await Promise.all([
|
||||
(hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo)({
|
||||
query,
|
||||
sort: encodedSort,
|
||||
filters,
|
||||
}),
|
||||
getProviders({ pageSize: 50 }),
|
||||
getScans({ pageSize: 50 }),
|
||||
findingId
|
||||
? getFindingById(findingId, "resources,scan.provider")
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
// Process the finding data to match the expected structure
|
||||
const processedFinding = findingByIdData?.data
|
||||
? (() => {
|
||||
const finding = findingByIdData.data;
|
||||
const included = findingByIdData.included || [];
|
||||
|
||||
// Build dictionaries from included data
|
||||
type IncludedItem = {
|
||||
type: string;
|
||||
id: string;
|
||||
attributes: Record<string, unknown>;
|
||||
relationships?: {
|
||||
provider?: { data?: { id: string } };
|
||||
};
|
||||
};
|
||||
|
||||
const resourceDict: Record<string, unknown> = {};
|
||||
const scanDict: Record<string, IncludedItem> = {};
|
||||
const providerDict: Record<string, unknown> = {};
|
||||
|
||||
included.forEach((item: IncludedItem) => {
|
||||
if (item.type === "resources") {
|
||||
resourceDict[item.id] = {
|
||||
id: item.id,
|
||||
attributes: item.attributes,
|
||||
};
|
||||
} else if (item.type === "scans") {
|
||||
scanDict[item.id] = item;
|
||||
} else if (item.type === "providers") {
|
||||
providerDict[item.id] = {
|
||||
id: item.id,
|
||||
attributes: item.attributes,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const scanId = finding.relationships?.scan?.data?.id;
|
||||
const resourceId = finding.relationships?.resources?.data?.[0]?.id;
|
||||
const scan = scanId ? scanDict[scanId] : undefined;
|
||||
const providerId = scan?.relationships?.provider?.data?.id;
|
||||
const resource = resourceId ? resourceDict[resourceId] : undefined;
|
||||
const provider = providerId ? providerDict[providerId] : undefined;
|
||||
|
||||
return {
|
||||
...finding,
|
||||
relationships: {
|
||||
scan: scan
|
||||
? { data: scan, attributes: scan.attributes }
|
||||
: undefined,
|
||||
resource: resource,
|
||||
provider: provider,
|
||||
},
|
||||
} as FindingProps;
|
||||
})()
|
||||
: null;
|
||||
const [metadataInfoData, providersData, scansData] = await Promise.all([
|
||||
(hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo)({
|
||||
query,
|
||||
sort: encodedSort,
|
||||
filters,
|
||||
}),
|
||||
getProviders({ pageSize: 50 }),
|
||||
getScans({ pageSize: 50 }),
|
||||
]);
|
||||
|
||||
// Extract unique regions, services, categories, groups from the new endpoint
|
||||
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
|
||||
@@ -154,7 +91,6 @@ export default async function Findings({
|
||||
<SSRDataTable searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</FilterTransitionWrapper>
|
||||
{processedFinding && <FindingDetailsSheet finding={processedFinding} />}
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
@@ -166,64 +102,45 @@ 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);
|
||||
// Key resets all client state (selection, drill-down) when data changes
|
||||
const groupKey = groups.map((g) => g.id).join(",");
|
||||
|
||||
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
|
||||
key={groupKey}
|
||||
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>
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
import { MuteRuleData } from "@/actions/mute-rules/types";
|
||||
import { VerticalDotsIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownDangerZone,
|
||||
@@ -24,20 +22,7 @@ export function MuteRuleRowActions({
|
||||
}: MuteRuleRowActionsProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-center px-2">
|
||||
<ActionDropdown
|
||||
trigger={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="size-7 rounded-full"
|
||||
>
|
||||
<VerticalDotsIcon
|
||||
size={16}
|
||||
className="text-text-neutral-secondary"
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ActionDropdown>
|
||||
<ActionDropdownItem
|
||||
icon={<Pencil />}
|
||||
label="Edit Mute Rule"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { formatDistanceToNow, parseISO } from "date-fns";
|
||||
import { BellRing, ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { formatRelativeTime } from "@/lib/date-utils";
|
||||
import { hasNewFeeds, markFeedsAsSeen } from "@/lib/feeds-storage";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -145,9 +145,7 @@ interface FeedTimelineItemProps {
|
||||
}
|
||||
|
||||
function FeedTimelineItem({ item, isLast }: FeedTimelineItemProps) {
|
||||
const relativeTime = formatDistanceToNow(parseISO(item.pubDate), {
|
||||
addSuffix: true,
|
||||
});
|
||||
const relativeTime = formatRelativeTime(item.pubDate);
|
||||
|
||||
// Extract version from title if it's a GitHub release
|
||||
const versionMatch = item.title.match(/v?(\d+\.\d+\.\d+)/);
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { FindingProps } from "@/types/components";
|
||||
|
||||
import { FindingDetail } from "./table/finding-detail";
|
||||
|
||||
interface FindingDetailsSheetProps {
|
||||
finding: FindingProps;
|
||||
}
|
||||
|
||||
export const FindingDetailsSheet = ({ finding }: FindingDetailsSheetProps) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete("id");
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={true} onOpenChange={handleOpenChange}>
|
||||
<SheetContent className="my-4 max-h-[calc(100vh-2rem)] max-w-[95vw] overflow-y-auto pt-10 md:my-8 md:max-h-[calc(100vh-4rem)] md:max-w-[55vw]">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="sr-only">Finding Details</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
View the finding details
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<FindingDetail findingDetails={finding} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { VolumeX } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
|
||||
import { MuteFindingsModal } from "./mute-findings-modal";
|
||||
|
||||
@@ -11,32 +12,70 @@ interface FloatingMuteButtonProps {
|
||||
selectedCount: number;
|
||||
selectedFindingIds: string[];
|
||||
onComplete?: () => void;
|
||||
/** Async resolver that returns actual finding UUIDs before opening modal */
|
||||
onBeforeOpen?: () => Promise<string[]>;
|
||||
/** When true, the toast warns that processing may take a few minutes */
|
||||
isBulkOperation?: boolean;
|
||||
/** Custom button label. Defaults to "Mute ({selectedCount})" */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function FloatingMuteButton({
|
||||
selectedCount,
|
||||
selectedFindingIds,
|
||||
onComplete,
|
||||
onBeforeOpen,
|
||||
isBulkOperation = false,
|
||||
label,
|
||||
}: FloatingMuteButtonProps) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [resolvedIds, setResolvedIds] = useState<string[]>([]);
|
||||
const [isResolving, setIsResolving] = useState(false);
|
||||
|
||||
const handleClick = async () => {
|
||||
if (onBeforeOpen) {
|
||||
setIsResolving(true);
|
||||
const ids = await onBeforeOpen();
|
||||
setResolvedIds(ids);
|
||||
setIsResolving(false);
|
||||
if (ids.length > 0) {
|
||||
setIsModalOpen(true);
|
||||
}
|
||||
} else {
|
||||
setIsModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
setResolvedIds([]);
|
||||
onComplete?.();
|
||||
};
|
||||
|
||||
const findingIds = onBeforeOpen ? resolvedIds : selectedFindingIds;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MuteFindingsModal
|
||||
isOpen={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
findingIds={selectedFindingIds}
|
||||
onComplete={onComplete}
|
||||
findingIds={findingIds}
|
||||
onComplete={handleComplete}
|
||||
isBulkOperation={isBulkOperation}
|
||||
/>
|
||||
|
||||
<div className="animate-in fade-in slide-in-from-bottom-4 fixed right-6 bottom-6 z-50 duration-300">
|
||||
<Button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
onClick={handleClick}
|
||||
disabled={isResolving}
|
||||
size="lg"
|
||||
className="shadow-lg"
|
||||
>
|
||||
<VolumeX className="size-5" />
|
||||
Mute ({selectedCount})
|
||||
{isResolving ? (
|
||||
<Spinner className="size-5" />
|
||||
) : (
|
||||
<VolumeX className="size-5" />
|
||||
)}
|
||||
{label ?? `Mute (${selectedCount})`}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export * from "./finding-details-sheet";
|
||||
export * from "./muted";
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
interface MarkdownContainerProps {
|
||||
children: string;
|
||||
}
|
||||
|
||||
export const MarkdownContainer = ({ children }: MarkdownContainerProps) => (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none break-words whitespace-normal">
|
||||
<ReactMarkdown>{children}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
@@ -1,14 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Input, Textarea } from "@heroui/input";
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition,
|
||||
} from "react";
|
||||
import { Dispatch, SetStateAction, useState, useTransition } from "react";
|
||||
|
||||
import { createMuteRule } from "@/actions/mute-rules";
|
||||
import { MuteRuleActionState } from "@/actions/mute-rules/types";
|
||||
@@ -21,6 +14,7 @@ interface MuteFindingsModalProps {
|
||||
onOpenChange: Dispatch<SetStateAction<boolean>>;
|
||||
findingIds: string[];
|
||||
onComplete?: () => void;
|
||||
isBulkOperation?: boolean;
|
||||
}
|
||||
|
||||
export function MuteFindingsModal({
|
||||
@@ -28,35 +22,12 @@ export function MuteFindingsModal({
|
||||
onOpenChange,
|
||||
findingIds,
|
||||
onComplete,
|
||||
isBulkOperation = false,
|
||||
}: MuteFindingsModalProps) {
|
||||
const { toast } = useToast();
|
||||
const [state, setState] = useState<MuteRuleActionState | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// Use refs to avoid stale closures in useEffect
|
||||
const onCompleteRef = useRef(onComplete);
|
||||
onCompleteRef.current = onComplete;
|
||||
|
||||
const onOpenChangeRef = useRef(onOpenChange);
|
||||
onOpenChangeRef.current = onOpenChange;
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.success) {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: state.success,
|
||||
});
|
||||
onCompleteRef.current?.();
|
||||
onOpenChangeRef.current(false);
|
||||
} else if (state?.errors?.general) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: state.errors.general,
|
||||
});
|
||||
}
|
||||
}, [state, toast]);
|
||||
|
||||
const handleCancel = () => {
|
||||
onOpenChange(false);
|
||||
};
|
||||
@@ -77,6 +48,24 @@ export function MuteFindingsModal({
|
||||
startTransition(() => {
|
||||
void (async () => {
|
||||
const result = await createMuteRule(null, formData);
|
||||
if (!result) return;
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: isBulkOperation
|
||||
? "Mute rule created. It may take a few minutes for all findings to update."
|
||||
: result.success,
|
||||
});
|
||||
onComplete?.();
|
||||
onOpenChange(false);
|
||||
} else if (result.errors?.general) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: result.errors.general,
|
||||
});
|
||||
}
|
||||
setState(result);
|
||||
})();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"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;
|
||||
expandedCheckId?: string | null;
|
||||
/** True when the expanded group has individually selected resources */
|
||||
hasResourceSelection?: boolean;
|
||||
}
|
||||
|
||||
export function getColumnFindingGroups({
|
||||
rowSelection,
|
||||
selectableRowCount,
|
||||
onDrillDown,
|
||||
expandedCheckId,
|
||||
hasResourceSelection = false,
|
||||
}: 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 allMuted =
|
||||
group.mutedCount > 0 && group.mutedCount === group.resourcesTotal;
|
||||
const isExpanded = expandedCheckId === group.checkId;
|
||||
|
||||
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} isMuted={allMuted} />
|
||||
<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={cn(
|
||||
"text-text-neutral-secondary size-4 transition-transform duration-200",
|
||||
isExpanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<Checkbox
|
||||
size="sm"
|
||||
checked={
|
||||
rowSelection[row.id] && isExpanded && hasResourceSelection
|
||||
? "indeterminate"
|
||||
: !!rowSelection[row.id]
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
// When indeterminate (resources selected), clicking deselects the group
|
||||
if (
|
||||
rowSelection[row.id] &&
|
||||
isExpanded &&
|
||||
hasResourceSelection
|
||||
) {
|
||||
row.toggleSelected(false);
|
||||
} else {
|
||||
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 }) => {
|
||||
const rawStatus = row.original.status as string;
|
||||
const status = rawStatus === "MUTED" ? "FAIL" : row.original.status;
|
||||
return <StatusFindingBadge status={status} />;
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
// Finding title column
|
||||
{
|
||||
accessorKey: "finding",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title="Finding Groups"
|
||||
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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef, Row, RowSelectionState } from "@tanstack/react-table";
|
||||
import { Container, CornerDownRight, VolumeOff, VolumeX } from "lucide-react";
|
||||
import { useContext, useState } from "react";
|
||||
|
||||
import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
|
||||
import { Checkbox } from "@/components/shadcn";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { InfoField } from "@/components/shadcn/info-field/info-field";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import { DateWithTime } from "@/components/ui/entities";
|
||||
import { EntityInfo } from "@/components/ui/entities/entity-info";
|
||||
import { SeverityBadge } from "@/components/ui/table";
|
||||
import { DataTableColumnHeader } from "@/components/ui/table/data-table-column-header";
|
||||
import {
|
||||
type FindingStatus,
|
||||
StatusFindingBadge,
|
||||
} from "@/components/ui/table/status-finding-badge";
|
||||
import { getFailingForLabel } from "@/lib/date-utils";
|
||||
import { FindingResourceRow } from "@/types";
|
||||
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
import { NotificationIndicator } from "./notification-indicator";
|
||||
|
||||
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, onMuteComplete } =
|
||||
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([]);
|
||||
onMuteComplete?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!resource.isMuted && (
|
||||
<MuteFindingsModal
|
||||
isOpen={isMuteModalOpen}
|
||||
onOpenChange={setIsMuteModalOpen}
|
||||
findingIds={resolvedIds}
|
||||
onComplete={handleMuteComplete}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="flex items-center justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ActionDropdown ariaLabel="Resource actions">
|
||||
<ActionDropdownItem
|
||||
icon={
|
||||
resource.isMuted ? (
|
||||
<VolumeOff className="size-5" />
|
||||
) : isResolving ? (
|
||||
<Spinner className="size-5" />
|
||||
) : (
|
||||
<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: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Resource" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="max-w-[240px]">
|
||||
<EntityInfo
|
||||
nameIcon={<Container className="size-4" />}
|
||||
entityAlias={row.original.resourceGroup}
|
||||
entityId={row.original.resourceUid}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
// Status
|
||||
{
|
||||
id: "status",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Status" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const rawStatus = row.original.status;
|
||||
const status =
|
||||
rawStatus === "MUTED" ? "FAIL" : (rawStatus as FindingStatus);
|
||||
return <StatusFindingBadge status={status} />;
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
// Service
|
||||
{
|
||||
id: "service",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Service" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<p className="text-text-neutral-primary max-w-[100px] truncate text-sm">
|
||||
{row.original.service}
|
||||
</p>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
// Region
|
||||
{
|
||||
id: "region",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Region" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<p className="text-text-neutral-primary max-w-[120px] truncate text-sm">
|
||||
{row.original.region}
|
||||
</p>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
// Severity
|
||||
{
|
||||
id: "severity",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Severity" />
|
||||
),
|
||||
cell: ({ row }) => <SeverityBadge severity={row.original.severity} />,
|
||||
enableSorting: false,
|
||||
},
|
||||
// Account — alias + uid (EntityInfo with provider logo)
|
||||
{
|
||||
id: "account",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Account" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="max-w-[240px]">
|
||||
<EntityInfo
|
||||
cloudProvider={row.original.providerType}
|
||||
entityAlias={row.original.providerAlias}
|
||||
entityId={row.original.providerUid}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
// Last seen
|
||||
{
|
||||
id: "lastSeen",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Last seen" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<InfoField label="Last seen" variant="compact">
|
||||
<DateWithTime dateTime={row.original.lastSeenAt} inline />
|
||||
</InfoField>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
// Failing for — duration since first_seen_at
|
||||
{
|
||||
id: "failingFor",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Failing for" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const duration = getFailingForLabel(row.original.firstSeenAt);
|
||||
return (
|
||||
<InfoField label="Failing for" variant="compact">
|
||||
{duration || "-"}
|
||||
</InfoField>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
// Actions column — mute only
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <div className="w-10" />,
|
||||
cell: ({ row }) => <ResourceRowActions row={row} />,
|
||||
enableSorting: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// TODO: Legacy columns — used by overview dashboard (column-new-findings-to-date.tsx).
|
||||
// Migrate that consumer to grouped view columns, then delete this file.
|
||||
"use client";
|
||||
|
||||
import { ColumnDef, RowSelectionState } from "@tanstack/react-table";
|
||||
|
||||
@@ -7,23 +7,48 @@ import { useContext, useState } from "react";
|
||||
|
||||
import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
|
||||
import { SendToJiraModal } from "@/components/findings/send-to-jira-modal";
|
||||
import { VerticalDotsIcon } from "@/components/icons";
|
||||
import { JiraIcon } from "@/components/icons/services/IconServices";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
|
||||
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;
|
||||
checkId?: 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 +65,68 @@ 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);
|
||||
|
||||
// For group rows, use checkId (for the resolve API); for regular findings, use id (UUID).
|
||||
const isGroup = finding.rowType === "group";
|
||||
const muteKey = isGroup ? (finding.checkId ?? finding.id) : finding.id;
|
||||
|
||||
// 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 isCurrentSelected = selectedFindingIds.includes(muteKey);
|
||||
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];
|
||||
return [muteKey];
|
||||
};
|
||||
|
||||
const getMuteLabel = () => {
|
||||
if (isMuted) return "Muted";
|
||||
const ids = getMuteIds();
|
||||
const ids = getDisplayIds();
|
||||
if (ids.length > 1) {
|
||||
return `Mute ${ids.length} Findings`;
|
||||
return `Mute ${ids.length} ${isGroup ? "Finding Groups" : "Findings"}`;
|
||||
}
|
||||
return isGroup ? "Mute Finding Group" : "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);
|
||||
}
|
||||
return "Mute Finding";
|
||||
};
|
||||
|
||||
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
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -90,55 +135,46 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
|
||||
return (
|
||||
<>
|
||||
<SendToJiraModal
|
||||
isOpen={isJiraModalOpen}
|
||||
onOpenChange={setIsJiraModalOpen}
|
||||
findingId={finding.id}
|
||||
findingTitle={findingTitle}
|
||||
/>
|
||||
{!isGroup && (
|
||||
<SendToJiraModal
|
||||
isOpen={isJiraModalOpen}
|
||||
onOpenChange={setIsJiraModalOpen}
|
||||
findingId={finding.id}
|
||||
findingTitle={findingTitle}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MuteFindingsModal
|
||||
isOpen={isMuteModalOpen}
|
||||
onOpenChange={setIsMuteModalOpen}
|
||||
findingIds={getMuteIds()}
|
||||
findingIds={resolvedIds}
|
||||
onComplete={handleMuteComplete}
|
||||
isBulkOperation={finding.rowType === "group"}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<ActionDropdown
|
||||
trigger={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Finding actions"
|
||||
className="hover:bg-bg-neutral-tertiary rounded-md p-1 transition-colors"
|
||||
>
|
||||
<VerticalDotsIcon
|
||||
size={20}
|
||||
className="text-text-neutral-secondary"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
ariaLabel="Finding actions"
|
||||
>
|
||||
<ActionDropdown ariaLabel="Finding actions">
|
||||
<ActionDropdownItem
|
||||
icon={
|
||||
isMuted ? (
|
||||
<VolumeOff className="size-5" />
|
||||
) : isResolving ? (
|
||||
<Spinner className="size-5" />
|
||||
) : (
|
||||
<VolumeX className="size-5" />
|
||||
)
|
||||
}
|
||||
label={getMuteLabel()}
|
||||
disabled={isMuted}
|
||||
onSelect={() => {
|
||||
setIsMuteModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
<ActionDropdownItem
|
||||
icon={<JiraIcon size={20} />}
|
||||
label="Send to Jira"
|
||||
onSelect={() => setIsJiraModalOpen(true)}
|
||||
label={isResolving ? "Resolving..." : getMuteLabel()}
|
||||
disabled={isMuted || isResolving}
|
||||
onSelect={handleMuteClick}
|
||||
/>
|
||||
{!isGroup && (
|
||||
<ActionDropdownItem
|
||||
icon={<JiraIcon size={20} />}
|
||||
label="Send to Jira"
|
||||
onSelect={() => setIsJiraModalOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Tooltip } from "@heroui/tooltip";
|
||||
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { DOCS_URLS } from "@/lib/external-urls";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -10,9 +13,20 @@ interface DeltaIndicatorProps {
|
||||
|
||||
export const DeltaIndicator = ({ delta }: DeltaIndicatorProps) => {
|
||||
return (
|
||||
<Tooltip
|
||||
className="pointer-events-auto"
|
||||
content={
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 w-2 min-w-2 cursor-pointer rounded-full",
|
||||
delta === "new"
|
||||
? "bg-system-severity-high"
|
||||
: delta === "changed"
|
||||
? "bg-system-severity-low"
|
||||
: "bg-text-neutral-tertiary",
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="flex gap-1 text-xs">
|
||||
<span>
|
||||
{delta === "new"
|
||||
@@ -35,18 +49,7 @@ export const DeltaIndicator = ({ delta }: DeltaIndicatorProps) => {
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 w-2 min-w-2 cursor-pointer rounded-full",
|
||||
delta === "new"
|
||||
? "bg-system-severity-high"
|
||||
: delta === "changed"
|
||||
? "bg-system-severity-low"
|
||||
: "bg-gray-500",
|
||||
)}
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// TODO: Legacy component — used by /resources page and overview dashboard.
|
||||
// Migrate those consumers to the new resource-detail-drawer, then delete this file.
|
||||
"use client";
|
||||
|
||||
import { ExternalLink, Link, VolumeX, X } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { type ReactNode, useState } from "react";
|
||||
|
||||
import {
|
||||
Button,
|
||||
@@ -33,41 +34,20 @@ import {
|
||||
FindingStatus,
|
||||
StatusFindingBadge,
|
||||
} from "@/components/ui/table/status-finding-badge";
|
||||
import { formatDuration } from "@/lib/date-utils";
|
||||
import { buildGitFileUrl, extractLineRangeFromUid } from "@/lib/iac-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FindingProps, ProviderType } from "@/types";
|
||||
|
||||
import { MarkdownContainer } from "../markdown-container";
|
||||
import { MuteFindingsModal } from "../mute-findings-modal";
|
||||
import { Muted } from "../muted";
|
||||
import { DeltaIndicator } from "./delta-indicator";
|
||||
|
||||
const MarkdownContainer = ({ children }: { children: string }) => {
|
||||
return (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none break-words whitespace-normal">
|
||||
<ReactMarkdown>{children}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderValue = (value: string | null | undefined) => {
|
||||
return value && value.trim() !== "" ? value : "-";
|
||||
};
|
||||
|
||||
// Add new utility function for duration formatting
|
||||
const formatDuration = (seconds: number) => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
|
||||
const parts = [];
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0) parts.push(`${minutes}m`);
|
||||
if (remainingSeconds > 0 || parts.length === 0)
|
||||
parts.push(`${remainingSeconds}s`);
|
||||
|
||||
return parts.join(" ");
|
||||
};
|
||||
|
||||
interface FindingDetailProps {
|
||||
findingDetails: FindingProps;
|
||||
trigger?: ReactNode;
|
||||
@@ -93,12 +73,6 @@ export const FindingDetail = ({
|
||||
const searchParams = useSearchParams();
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
|
||||
const [activeTab, setActiveTab] = useState("general");
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab("general");
|
||||
}, [findingDetails.id]);
|
||||
|
||||
const copyFindingUrl = () => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("id", findingDetails.id);
|
||||
@@ -179,7 +153,7 @@ export const FindingDetail = ({
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<Tabs key={findingDetails.id} defaultValue="general" className="w-full">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<TabsList>
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
Row,
|
||||
RowSelectionState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { resolveFindingIds } from "@/actions/findings/findings-by-resource";
|
||||
import { Spinner } from "@/components/shadcn/spinner/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 { FloatingMuteButton } from "../floating-mute-button";
|
||||
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 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 = Object.fromEntries(searchParams.entries());
|
||||
const hasDateOrScan = hasDateOrScanFilter(currentParams);
|
||||
|
||||
// Extract filter params from search params
|
||||
const filters: Record<string, string> = {};
|
||||
searchParams.forEach((value, key) => {
|
||||
if (key.startsWith("filter[") || key.includes("__in")) {
|
||||
filters[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
const handleSetResources = (
|
||||
newResources: FindingResourceRow[],
|
||||
_hasMore: boolean,
|
||||
) => {
|
||||
setResources(newResources);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleAppendResources = (
|
||||
newResources: FindingResourceRow[],
|
||||
_hasMore: boolean,
|
||||
) => {
|
||||
setResources((prev) => [...prev, ...newResources]);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleSetLoading = (loading: boolean) => {
|
||||
setIsLoading(loading);
|
||||
};
|
||||
|
||||
const { sentinelRef, refresh, loadMore } = useInfiniteResources({
|
||||
checkId: group.checkId,
|
||||
hasDateOrScanFilter: hasDateOrScan,
|
||||
filters,
|
||||
onSetResources: handleSetResources,
|
||||
onAppendResources: handleAppendResources,
|
||||
onSetLoading: handleSetLoading,
|
||||
});
|
||||
|
||||
// Resource detail drawer
|
||||
const drawer = useResourceDetailDrawer({
|
||||
resources,
|
||||
checkId: group.checkId,
|
||||
totalResourceCount: group.resourcesTotal,
|
||||
onRequestMoreResources: loadMore,
|
||||
});
|
||||
|
||||
const handleDrawerMuteComplete = () => {
|
||||
drawer.refetchCurrent();
|
||||
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);
|
||||
|
||||
/** Converts resource_ids (display) → resourceUids → finding UUIDs via API. */
|
||||
const resolveResourceIds = 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,
|
||||
filters,
|
||||
hasDateOrScanFilter: hasDateOrScan,
|
||||
});
|
||||
};
|
||||
|
||||
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();
|
||||
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,
|
||||
onMuteComplete: handleMuteComplete,
|
||||
}}
|
||||
>
|
||||
<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 className="hover:bg-transparent">
|
||||
<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">
|
||||
<Spinner 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 && (
|
||||
<FloatingMuteButton
|
||||
selectedCount={selectedFindingIds.length}
|
||||
selectedFindingIds={selectedFindingIds}
|
||||
onBeforeOpen={async () => {
|
||||
return resolveResourceIds(selectedFindingIds);
|
||||
}}
|
||||
onComplete={handleMuteComplete}
|
||||
isBulkOperation
|
||||
/>
|
||||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
"use client";
|
||||
|
||||
import { Row, RowSelectionState } from "@tanstack/react-table";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
resolveFindingIds,
|
||||
resolveFindingIdsByVisibleGroupResources,
|
||||
} from "@/actions/findings/findings-by-resource";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { hasDateOrScanFilter } from "@/lib";
|
||||
import { FindingGroupRow, MetaDataProps } from "@/types";
|
||||
|
||||
import { FloatingMuteButton } from "../floating-mute-button";
|
||||
import { getColumnFindingGroups } from "./column-finding-groups";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
import {
|
||||
InlineResourceContainer,
|
||||
InlineResourceContainerHandle,
|
||||
} from "./inline-resource-container";
|
||||
|
||||
function buildMuteLabel(groupCount: number, resourceCount: number): string {
|
||||
const parts: string[] = [];
|
||||
if (groupCount > 0) {
|
||||
parts.push(`${groupCount} ${groupCount === 1 ? "Group" : "Groups"}`);
|
||||
}
|
||||
if (resourceCount > 0) {
|
||||
parts.push(
|
||||
`${resourceCount} ${resourceCount === 1 ? "Resource" : "Resources"}`,
|
||||
);
|
||||
}
|
||||
return `Mute ${parts.join(" and ")}`;
|
||||
}
|
||||
|
||||
interface FindingsGroupTableProps {
|
||||
data: FindingGroupRow[];
|
||||
metadata?: MetaDataProps;
|
||||
}
|
||||
|
||||
export function FindingsGroupTable({
|
||||
data,
|
||||
metadata,
|
||||
}: FindingsGroupTableProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [expandedCheckId, setExpandedCheckId] = useState<string | null>(null);
|
||||
const [expandedGroup, setExpandedGroup] = useState<FindingGroupRow | null>(
|
||||
null,
|
||||
);
|
||||
const [resourceSearch, setResourceSearch] = useState("");
|
||||
const [resourceSelection, setResourceSelection] = useState<string[]>([]);
|
||||
const inlineRef = useRef<InlineResourceContainerHandle>(null);
|
||||
|
||||
// State resets (selection, drill-down) are handled by the parent via
|
||||
// key={groupKey} — when data changes, the component remounts with fresh state.
|
||||
|
||||
const safeData = data ?? [];
|
||||
const hasResourceSelection = resourceSelection.length > 0;
|
||||
const currentParams = Object.fromEntries(searchParams.entries());
|
||||
const hasDateOrScan = hasDateOrScanFilter(currentParams);
|
||||
|
||||
const filters: Record<string, string> = {};
|
||||
searchParams.forEach((value, key) => {
|
||||
if (key.startsWith("filter[")) {
|
||||
filters[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Get selected group check IDs. When the expanded group has individual resource
|
||||
// selections, exclude it from group-level mute targets — the resource-level
|
||||
// FloatingMuteButton handles those.
|
||||
const selectedCheckIds = Object.keys(rowSelection)
|
||||
.filter((key) => rowSelection[key])
|
||||
.map((idx) => safeData[parseInt(idx)]?.checkId)
|
||||
.filter(Boolean)
|
||||
.filter(
|
||||
(checkId) => !(hasResourceSelection && checkId === expandedCheckId),
|
||||
);
|
||||
|
||||
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 selectedCheckIds.includes(id);
|
||||
};
|
||||
|
||||
const resolveGroupMuteIds = async (checkIds: string[]) => {
|
||||
const results = await Promise.all(
|
||||
checkIds.map((checkId) =>
|
||||
resolveFindingIdsByVisibleGroupResources({
|
||||
checkId,
|
||||
filters,
|
||||
hasDateOrScanFilter: hasDateOrScan,
|
||||
resourceSearch:
|
||||
checkId === expandedCheckId && resourceSearch
|
||||
? resourceSearch
|
||||
: undefined,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return Array.from(new Set(results.flat()));
|
||||
};
|
||||
|
||||
/** Shared resolver for group row action dropdowns (via context). */
|
||||
const resolveMuteIds = async (checkIds: string[]) =>
|
||||
resolveGroupMuteIds(checkIds);
|
||||
|
||||
const handleMuteComplete = () => {
|
||||
clearSelection();
|
||||
setResourceSelection([]);
|
||||
inlineRef.current?.clearSelection();
|
||||
inlineRef.current?.refresh();
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleDrillDown = (checkId: string, group: FindingGroupRow) => {
|
||||
// Toggle: same group = collapse, different = switch
|
||||
if (expandedCheckId === checkId) {
|
||||
handleCollapse();
|
||||
return;
|
||||
}
|
||||
setExpandedCheckId(checkId);
|
||||
setExpandedGroup(group);
|
||||
setResourceSearch("");
|
||||
setResourceSelection([]);
|
||||
};
|
||||
|
||||
const handleCollapse = () => {
|
||||
setExpandedCheckId(null);
|
||||
setExpandedGroup(null);
|
||||
setResourceSearch("");
|
||||
setResourceSelection([]);
|
||||
};
|
||||
|
||||
const columns = getColumnFindingGroups({
|
||||
rowSelection,
|
||||
selectableRowCount,
|
||||
onDrillDown: handleDrillDown,
|
||||
expandedCheckId,
|
||||
hasResourceSelection,
|
||||
});
|
||||
|
||||
const renderAfterRow = (row: Row<FindingGroupRow>) => {
|
||||
const group = row.original;
|
||||
if (group.checkId !== expandedCheckId || !expandedGroup) return null;
|
||||
|
||||
return (
|
||||
<InlineResourceContainer
|
||||
ref={inlineRef}
|
||||
key={`${group.checkId}|${searchParams.toString()}|${resourceSearch}`}
|
||||
group={expandedGroup}
|
||||
resourceSearch={resourceSearch}
|
||||
columnCount={columns.length}
|
||||
onResourceSelectionChange={setResourceSelection}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<FindingsSelectionContext.Provider
|
||||
value={{
|
||||
selectedFindingIds: selectedCheckIds,
|
||||
selectedFindings,
|
||||
clearSelection,
|
||||
isSelected,
|
||||
resolveMuteIds,
|
||||
}}
|
||||
>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={safeData}
|
||||
metadata={metadata}
|
||||
enableRowSelection
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
getRowCanSelect={getRowCanSelect}
|
||||
showSearch
|
||||
searchPlaceholder={
|
||||
expandedCheckId ? "Search resources..." : "Search by name"
|
||||
}
|
||||
controlledSearch={expandedCheckId ? resourceSearch : undefined}
|
||||
onSearchChange={expandedCheckId ? setResourceSearch : undefined}
|
||||
searchBadge={
|
||||
expandedGroup
|
||||
? { label: expandedGroup.checkTitle, onDismiss: handleCollapse }
|
||||
: undefined
|
||||
}
|
||||
renderAfterRow={renderAfterRow}
|
||||
/>
|
||||
|
||||
{(selectedCheckIds.length > 0 || hasResourceSelection) && (
|
||||
<FloatingMuteButton
|
||||
selectedCount={selectedCheckIds.length + resourceSelection.length}
|
||||
selectedFindingIds={[...selectedCheckIds, ...resourceSelection]}
|
||||
label={buildMuteLabel(
|
||||
selectedCheckIds.length,
|
||||
resourceSelection.length,
|
||||
)}
|
||||
onBeforeOpen={async () => {
|
||||
const [groupIds, resourceIds] = await Promise.all([
|
||||
selectedCheckIds.length > 0
|
||||
? resolveGroupMuteIds(selectedCheckIds)
|
||||
: Promise.resolve([]),
|
||||
hasResourceSelection && expandedCheckId
|
||||
? resolveFindingIds({
|
||||
checkId: expandedCheckId,
|
||||
resourceUids: resourceSelection,
|
||||
filters,
|
||||
hasDateOrScanFilter: hasDateOrScan,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
return [...groupIds, ...resourceIds];
|
||||
}}
|
||||
onComplete={handleMuteComplete}
|
||||
isBulkOperation={
|
||||
selectedCheckIds.length > 0 || resourceSelection.length > 1
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</FindingsSelectionContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -2,13 +2,17 @@
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import { FindingProps } from "@/types";
|
||||
import { FindingGroupRow, FindingProps } from "@/types";
|
||||
|
||||
interface FindingsSelectionContextValue {
|
||||
selectedFindingIds: string[];
|
||||
selectedFindings: FindingProps[];
|
||||
selectedFindings: (FindingProps | FindingGroupRow)[];
|
||||
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[]>;
|
||||
/** Called after a mute operation completes to refresh data. */
|
||||
onMuteComplete?: () => void;
|
||||
}
|
||||
|
||||
export const FindingsSelectionContext =
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Row, RowSelectionState } from "@tanstack/react-table";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { FindingProps, MetaDataProps } from "@/types";
|
||||
|
||||
import { FloatingMuteButton } from "../floating-mute-button";
|
||||
import { getColumnFindings } from "./column-findings";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
|
||||
interface FindingsTableWithSelectionProps {
|
||||
data: FindingProps[];
|
||||
metadata?: MetaDataProps;
|
||||
}
|
||||
|
||||
export function FindingsTableWithSelection({
|
||||
data,
|
||||
metadata,
|
||||
}: FindingsTableWithSelectionProps) {
|
||||
const router = useRouter();
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
// Track the finding IDs to detect when data changes (e.g., after muting)
|
||||
const currentFindingIds = (data ?? []).map((f) => f.id).join(",");
|
||||
const previousFindingIdsRef = useRef(currentFindingIds);
|
||||
|
||||
// Reset selection when page changes
|
||||
useEffect(() => {
|
||||
setRowSelection({});
|
||||
}, [metadata?.pagination?.page]);
|
||||
|
||||
// Reset selection when the data changes (e.g., after muting a finding)
|
||||
// This prevents the wrong findings from appearing selected after refresh
|
||||
useEffect(() => {
|
||||
if (previousFindingIdsRef.current !== currentFindingIds) {
|
||||
setRowSelection({});
|
||||
previousFindingIdsRef.current = currentFindingIds;
|
||||
}
|
||||
}, [currentFindingIds]);
|
||||
|
||||
// Ensure data is always an array for safe operations
|
||||
const safeData = data ?? [];
|
||||
|
||||
// Get selected finding IDs and data (only non-muted findings 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 (non-muted findings only)
|
||||
const selectableRowCount = safeData.filter((f) => !f.attributes.muted).length;
|
||||
|
||||
// Function to determine if a row can be selected (muted findings cannot be selected)
|
||||
const getRowCanSelect = (row: Row<FindingProps>): boolean => {
|
||||
return !row.original.attributes.muted;
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
setRowSelection({});
|
||||
};
|
||||
|
||||
const isSelected = (id: string) => {
|
||||
return selectedFindingIds.includes(id);
|
||||
};
|
||||
|
||||
// Handle mute completion: clear selection and refresh data
|
||||
const handleMuteComplete = () => {
|
||||
clearSelection();
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
// Generate columns with access to rowSelection state and selectable row count
|
||||
const columns = getColumnFindings(rowSelection, selectableRowCount);
|
||||
|
||||
return (
|
||||
<FindingsSelectionContext.Provider
|
||||
value={{
|
||||
selectedFindingIds,
|
||||
selectedFindings,
|
||||
clearSelection,
|
||||
isSelected,
|
||||
}}
|
||||
>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={safeData}
|
||||
metadata={metadata}
|
||||
enableRowSelection
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
getRowCanSelect={getRowCanSelect}
|
||||
showSearch
|
||||
/>
|
||||
|
||||
{selectedFindingIds.length > 0 && (
|
||||
<FloatingMuteButton
|
||||
selectedCount={selectedFindingIds.length}
|
||||
selectedFindingIds={selectedFindingIds}
|
||||
onComplete={handleMuteComplete}
|
||||
/>
|
||||
)}
|
||||
</FindingsSelectionContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { ProviderType } from "@/types";
|
||||
|
||||
import { ProviderIconCell } from "./provider-icon-cell";
|
||||
|
||||
const MAX_VISIBLE_PROVIDERS = 3;
|
||||
|
||||
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) => (
|
||||
<ProviderIconCell
|
||||
key={provider}
|
||||
provider={provider}
|
||||
size={28}
|
||||
className="size-7"
|
||||
/>
|
||||
))}
|
||||
{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,18 @@
|
||||
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";
|
||||
// TODO: Remove legacy exports once /resources and overview dashboard migrate to grouped view components
|
||||
// export * from "./column-findings";
|
||||
// export * from "./data-table-row-details";
|
||||
// export * from "./finding-detail";
|
||||
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,414 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
Row,
|
||||
RowSelectionState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ChevronsDown } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useImperativeHandle, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { resolveFindingIds } from "@/actions/findings/findings-by-resource";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import { useInfiniteResources } from "@/hooks/use-infinite-resources";
|
||||
import { useScrollHint } from "@/hooks/use-scroll-hint";
|
||||
import { hasDateOrScanFilter } from "@/lib";
|
||||
import { FindingGroupRow, FindingResourceRow } from "@/types";
|
||||
|
||||
import { getColumnFindingResources } from "./column-finding-resources";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
import {
|
||||
ResourceDetailDrawer,
|
||||
useResourceDetailDrawer,
|
||||
} from "./resource-detail-drawer";
|
||||
|
||||
export interface InlineResourceContainerHandle {
|
||||
/** Soft-refresh resources (re-fetch page 1 without skeletons). */
|
||||
refresh: () => void;
|
||||
/** Clear internal row selection and notify parent. */
|
||||
clearSelection: () => void;
|
||||
}
|
||||
|
||||
interface InlineResourceContainerProps {
|
||||
group: FindingGroupRow;
|
||||
resourceSearch: string;
|
||||
columnCount: number;
|
||||
/** Called with selected resource UIDs (not finding IDs) for parent-level mute resolution */
|
||||
onResourceSelectionChange: (resourceUids: string[]) => void;
|
||||
ref?: React.Ref<InlineResourceContainerHandle>;
|
||||
}
|
||||
|
||||
// NOTE: We intentionally do NOT auto-select child resources when a parent group
|
||||
// is selected. Group-level mute resolution now fetches the group's visible
|
||||
// resources separately. Auto-selecting children would still require syncing state
|
||||
// with infinite scroll (resources load 10 at a time), causing cascading setState
|
||||
// during render and confusing partial selections. Resource-level checkboxes are
|
||||
// for selecting a specific subset independently.
|
||||
|
||||
/** Max skeleton rows that fit in the 440px scroll container */
|
||||
const MAX_SKELETON_ROWS = 7;
|
||||
|
||||
function ResourceSkeletonRow() {
|
||||
return (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
{/* Select: indicator + corner arrow + checkbox */}
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-1.5 rounded-full" />
|
||||
<Skeleton className="size-4 rounded" />
|
||||
<div className="bg-bg-input-primary border-border-input-primary size-5 rounded-sm border shadow-[0_1px_2px_0_rgba(0,0,0,0.1)]" />
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* Resource: icon + name + uid */}
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-4 rounded" />
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-3.5 w-32 rounded" />
|
||||
<Skeleton className="h-3 w-20 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* Status */}
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-11 rounded-md" />
|
||||
</TableCell>
|
||||
{/* Service */}
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-16 rounded" />
|
||||
</TableCell>
|
||||
{/* Region */}
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-20 rounded" />
|
||||
</TableCell>
|
||||
{/* Severity */}
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-2 rounded-full" />
|
||||
<Skeleton className="h-4 w-12 rounded" />
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* Account: provider icon + alias + uid */}
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-4 rounded" />
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-3.5 w-24 rounded" />
|
||||
<Skeleton className="h-3 w-16 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* Last seen */}
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-24 rounded" />
|
||||
</TableCell>
|
||||
{/* Failing for */}
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-16 rounded" />
|
||||
</TableCell>
|
||||
{/* Actions */}
|
||||
<TableCell>
|
||||
<Skeleton className="size-6 rounded" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export function InlineResourceContainer({
|
||||
group,
|
||||
resourceSearch,
|
||||
columnCount,
|
||||
onResourceSelectionChange,
|
||||
ref,
|
||||
}: InlineResourceContainerProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [resources, setResources] = useState<FindingResourceRow[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Scroll hint: shows "scroll for more" when content overflows
|
||||
const {
|
||||
containerRef: scrollHintContainerRef,
|
||||
sentinelRef: scrollHintSentinelRef,
|
||||
showScrollHint,
|
||||
} = useScrollHint({ refreshToken: resources.length });
|
||||
|
||||
// Combine scrollContainerRef (for IntersectionObserver root) with scrollHintContainerRef
|
||||
const combinedScrollRef = (node: HTMLDivElement | null) => {
|
||||
scrollContainerRef.current = node;
|
||||
scrollHintContainerRef(node);
|
||||
};
|
||||
|
||||
// Derive hasDateOrScan from current URL params
|
||||
const currentParams = Object.fromEntries(searchParams.entries());
|
||||
const hasDateOrScan = hasDateOrScanFilter(currentParams);
|
||||
|
||||
// Extract filter params from search params, merge with local resource search
|
||||
const filters: Record<string, string> = {};
|
||||
searchParams.forEach((value, key) => {
|
||||
if (key.startsWith("filter[") || key.includes("__in")) {
|
||||
filters[key] = value;
|
||||
}
|
||||
});
|
||||
if (resourceSearch) {
|
||||
filters["filter[name__icontains]"] = resourceSearch;
|
||||
}
|
||||
|
||||
const handleSetResources = (
|
||||
newResources: FindingResourceRow[],
|
||||
_hasMore: boolean,
|
||||
) => {
|
||||
setResources(newResources);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleAppendResources = (
|
||||
newResources: FindingResourceRow[],
|
||||
_hasMore: boolean,
|
||||
) => {
|
||||
setResources((prev) => [...prev, ...newResources]);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleSetLoading = (loading: boolean) => {
|
||||
setIsLoading(loading);
|
||||
};
|
||||
|
||||
const { sentinelRef, refresh, loadMore } = useInfiniteResources({
|
||||
checkId: group.checkId,
|
||||
hasDateOrScanFilter: hasDateOrScan,
|
||||
filters,
|
||||
onSetResources: handleSetResources,
|
||||
onAppendResources: handleAppendResources,
|
||||
onSetLoading: handleSetLoading,
|
||||
scrollContainerRef,
|
||||
});
|
||||
|
||||
// Resource detail drawer
|
||||
const drawer = useResourceDetailDrawer({
|
||||
resources,
|
||||
checkId: group.checkId,
|
||||
totalResourceCount: group.resourcesTotal,
|
||||
onRequestMoreResources: loadMore,
|
||||
});
|
||||
|
||||
const handleDrawerMuteComplete = () => {
|
||||
drawer.refetchCurrent();
|
||||
refresh();
|
||||
};
|
||||
|
||||
// Selection logic
|
||||
const selectedFindingIds = Object.keys(rowSelection)
|
||||
.filter((key) => rowSelection[key])
|
||||
.map((idx) => resources[parseInt(idx)]?.findingId)
|
||||
.filter(Boolean);
|
||||
|
||||
const resolveResourceIds = 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,
|
||||
filters,
|
||||
hasDateOrScanFilter: hasDateOrScan,
|
||||
});
|
||||
};
|
||||
|
||||
const selectableRowCount = resources.filter((r) => !r.isMuted).length;
|
||||
|
||||
const getRowCanSelect = (row: Row<FindingResourceRow>): boolean => {
|
||||
return !row.original.isMuted;
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
setRowSelection({});
|
||||
onResourceSelectionChange([]);
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({ refresh, clearSelection }));
|
||||
|
||||
const isSelected = (id: string) => {
|
||||
return selectedFindingIds.includes(id);
|
||||
};
|
||||
|
||||
const handleMuteComplete = () => {
|
||||
clearSelection();
|
||||
refresh();
|
||||
};
|
||||
|
||||
const handleRowSelectionChange = (
|
||||
updater:
|
||||
| RowSelectionState
|
||||
| ((prev: RowSelectionState) => RowSelectionState),
|
||||
) => {
|
||||
const newSelection =
|
||||
typeof updater === "function" ? updater(rowSelection) : updater;
|
||||
setRowSelection(newSelection);
|
||||
|
||||
const newResourceUids = Object.keys(newSelection)
|
||||
.filter((key) => newSelection[key])
|
||||
.map((idx) => resources[parseInt(idx)]?.resourceUid)
|
||||
.filter(Boolean);
|
||||
onResourceSelectionChange(newResourceUids);
|
||||
};
|
||||
|
||||
const columns = getColumnFindingResources({
|
||||
rowSelection,
|
||||
selectableRowCount,
|
||||
});
|
||||
|
||||
const table = useReactTable({
|
||||
data: resources,
|
||||
columns,
|
||||
enableRowSelection: getRowCanSelect,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onRowSelectionChange: handleRowSelectionChange,
|
||||
manualPagination: true,
|
||||
state: {
|
||||
rowSelection,
|
||||
},
|
||||
});
|
||||
|
||||
const rows = table.getRowModel().rows;
|
||||
|
||||
return (
|
||||
<FindingsSelectionContext.Provider
|
||||
value={{
|
||||
selectedFindingIds,
|
||||
selectedFindings: [],
|
||||
clearSelection,
|
||||
isSelected,
|
||||
resolveMuteIds: resolveResourceIds,
|
||||
onMuteComplete: handleMuteComplete,
|
||||
}}
|
||||
>
|
||||
<tr>
|
||||
<td colSpan={columnCount} className="p-0">
|
||||
<AnimatePresence initial>
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={combinedScrollRef}
|
||||
className="max-h-[440px] overflow-y-auto pl-6"
|
||||
>
|
||||
{/* Resource rows or skeleton placeholder */}
|
||||
<table className="mt-[-10] w-full border-separate border-spacing-y-4">
|
||||
<tbody>
|
||||
{isLoading && rows.length === 0 ? (
|
||||
Array.from({
|
||||
length: Math.min(
|
||||
group.resourcesTotal,
|
||||
MAX_SKELETON_ROWS,
|
||||
),
|
||||
}).map((_, i) => <ResourceSkeletonRow key={i} />)
|
||||
) : rows.length > 0 ? (
|
||||
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>
|
||||
))
|
||||
) : (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No resources found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Spinner for infinite scroll (subsequent pages only) */}
|
||||
{isLoading && rows.length > 0 && (
|
||||
<div className="flex items-center justify-center gap-2 py-8">
|
||||
<Spinner className="size-6" />
|
||||
<span className="text-text-neutral-tertiary text-sm">
|
||||
Loading resources...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sentinel for scroll hint detection */}
|
||||
<div
|
||||
ref={scrollHintSentinelRef}
|
||||
aria-hidden
|
||||
className="h-px shrink-0"
|
||||
/>
|
||||
|
||||
{/* Sentinel for infinite scroll */}
|
||||
<div ref={sentinelRef} className="h-1" />
|
||||
</div>
|
||||
|
||||
{/* Gradients rendered after scroll container so they paint on top */}
|
||||
<div className="from-bg-neutral-secondary pointer-events-none absolute top-0 right-0 left-6 z-20 h-6 bg-gradient-to-b to-transparent" />
|
||||
<div className="from-bg-neutral-secondary pointer-events-none absolute right-0 bottom-0 left-6 z-20 h-6 bg-gradient-to-t to-transparent" />
|
||||
|
||||
{/* Scroll hint */}
|
||||
{showScrollHint && (
|
||||
<div className="pointer-events-none absolute right-0 bottom-0 left-6 z-30">
|
||||
<div className="absolute inset-x-0 bottom-2 flex justify-center">
|
||||
<div className="bg-bg-neutral-tertiary text-text-neutral-secondary animate-bounce rounded-full px-3 py-1 text-xs shadow-md">
|
||||
<ChevronsDown className="inline size-3.5" /> Scroll for
|
||||
more
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{createPortal(
|
||||
<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}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</FindingsSelectionContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -31,22 +31,34 @@ export const NotificationIndicator = ({
|
||||
}: NotificationIndicatorProps) => {
|
||||
// Muted takes precedence over delta
|
||||
if (isMuted) {
|
||||
const ruleName = mutedReason || "Unknown rule";
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="ml-1 flex cursor-pointer items-center justify-center">
|
||||
<div
|
||||
className="flex w-2 shrink-0 cursor-pointer items-center justify-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MutedIcon className="text-bg-data-muted size-2" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<TooltipContent onClick={(e) => e.stopPropagation()}>
|
||||
<Link
|
||||
href="/mutelist"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-button-tertiary hover:text-button-tertiary-hover flex items-center gap-1 text-xs underline-offset-4"
|
||||
>
|
||||
<span className="text-text-neutral-primary">Mute rule:</span>
|
||||
<span className="max-w-[150px] truncate">{ruleName}</span>
|
||||
{/* TODO: always show rule name once the API returns muted_reason in finding-group-resources */}
|
||||
{mutedReason ? (
|
||||
<>
|
||||
<span className="text-text-neutral-primary">Mute rule:</span>
|
||||
<span className="max-w-[150px] truncate">{mutedReason}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-text-neutral-primary">Mute rule:</span>
|
||||
<span>view rules</span>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -59,13 +71,18 @@ export const NotificationIndicator = ({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"ml-1 size-1.5 cursor-pointer rounded-full",
|
||||
delta === DeltaValues.NEW
|
||||
? "bg-system-severity-high"
|
||||
: "bg-system-severity-low",
|
||||
)}
|
||||
/>
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex w-2 shrink-0 cursor-pointer items-center justify-center"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
delta === DeltaValues.NEW
|
||||
? "bg-system-severity-high"
|
||||
: "bg-system-severity-low",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
@@ -96,5 +113,5 @@ export const NotificationIndicator = ({
|
||||
}
|
||||
|
||||
// No indicator - return minimal width placeholder
|
||||
return <div className="w-2" />;
|
||||
return <div className="w-2 shrink-0" />;
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
OpenStackProviderBadge,
|
||||
OracleCloudProviderBadge,
|
||||
} from "@/components/icons/providers-badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ProviderType } from "@/types";
|
||||
|
||||
export const PROVIDER_ICONS = {
|
||||
@@ -36,24 +37,31 @@ export const PROVIDER_ICONS = {
|
||||
interface ProviderIconCellProps {
|
||||
provider: ProviderType;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ProviderIconCell = ({
|
||||
provider,
|
||||
size = 26,
|
||||
className = "size-8 rounded-md bg-white",
|
||||
}: ProviderIconCellProps) => {
|
||||
const IconComponent = PROVIDER_ICONS[provider];
|
||||
|
||||
if (!IconComponent) {
|
||||
return (
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-white">
|
||||
<div className={cn("flex items-center justify-center", className)}>
|
||||
<span className="text-text-neutral-secondary text-xs">?</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-8 items-center justify-center overflow-hidden rounded-md bg-white">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<IconComponent width={size} height={size} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ResourceDetailDrawer } from "./resource-detail-drawer";
|
||||
export { useResourceDetailDrawer } from "./use-resource-detail-drawer";
|
||||
+767
@@ -0,0 +1,767 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
CircleArrowRight,
|
||||
CircleChevronLeft,
|
||||
CircleChevronRight,
|
||||
Container,
|
||||
ExternalLink,
|
||||
VolumeOff,
|
||||
VolumeX,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { ResourceDrawerFinding } from "@/actions/findings";
|
||||
import { MarkdownContainer } from "@/components/findings/markdown-container";
|
||||
import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
|
||||
import { SendToJiraModal } from "@/components/findings/send-to-jira-modal";
|
||||
import { getComplianceIcon } from "@/components/icons";
|
||||
import { JiraIcon } from "@/components/icons/services/IconServices";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
InfoField,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/shadcn";
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { EventsTimeline } from "@/components/shared/events-timeline/events-timeline";
|
||||
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 { getFailingForLabel } from "@/lib/date-utils";
|
||||
import { formatDuration } from "@/lib/date-utils";
|
||||
import { getRegionFlag } from "@/lib/region-flags";
|
||||
|
||||
import { Muted } from "../../muted";
|
||||
import { DeltaIndicator } from "../delta-indicator";
|
||||
import { NotificationIndicator } from "../notification-indicator";
|
||||
import { ResourceDetailSkeleton } from "./resource-detail-skeleton";
|
||||
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;
|
||||
}
|
||||
|
||||
export function ResourceDetailDrawerContent({
|
||||
isLoading,
|
||||
isNavigating,
|
||||
checkMeta,
|
||||
currentIndex,
|
||||
totalResources,
|
||||
currentFinding,
|
||||
otherFindings,
|
||||
onNavigatePrev,
|
||||
onNavigateNext,
|
||||
onMuteComplete,
|
||||
}: ResourceDetailDrawerContentProps) {
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
|
||||
|
||||
// Initial load — no check metadata yet
|
||||
if (!checkMeta && isLoading) {
|
||||
return (
|
||||
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
|
||||
{/* Header skeleton */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-6 w-14 rounded-md" />
|
||||
<Skeleton className="h-6 w-16 rounded-md" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-3/4 rounded" />
|
||||
</div>
|
||||
{/* Navigation skeleton */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-7 w-48 rounded" />
|
||||
<div className="flex gap-1">
|
||||
<Skeleton className="size-8 rounded-md" />
|
||||
<Skeleton className="size-8 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Resource card skeleton */}
|
||||
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex min-h-0 flex-1 flex-col gap-4 rounded-lg border p-4">
|
||||
<ResourceDetailSkeleton />
|
||||
</div>
|
||||
</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();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{f && (
|
||||
<SendToJiraModal
|
||||
isOpen={isJiraModalOpen}
|
||||
onOpenChange={setIsJiraModalOpen}
|
||||
findingId={f.id}
|
||||
findingTitle={checkMeta.checkTitle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<DeltaIndicator delta={f.delta} />
|
||||
<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 flex-col gap-1.5">
|
||||
<span className="text-text-neutral-tertiary text-xs font-medium">
|
||||
Compliance Frameworks:
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{checkMeta.complianceFrameworks.map((framework) => {
|
||||
const icon = getComplianceIcon(framework);
|
||||
return icon ? (
|
||||
<Tooltip key={framework}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-md border border-gray-300 bg-white p-0.5">
|
||||
<Image
|
||||
src={icon}
|
||||
alt={framework}
|
||||
width={20}
|
||||
height={20}
|
||||
className="size-5 object-contain"
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{framework}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip key={framework}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-text-neutral-secondary inline-flex h-7 shrink-0 items-center rounded-md border border-gray-300 bg-white px-1.5 text-xs">
|
||||
{framework}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{framework}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</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="text-text-neutral-secondary 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="text-text-neutral-secondary 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 minimal-scrollbar flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto rounded-lg border p-4">
|
||||
{/* Resource info — shows loading when currentFinding is not yet available */}
|
||||
{!f || isNavigating ? (
|
||||
<ResourceDetailSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Resource info grid — 4 data columns */}
|
||||
<div className="grid min-w-0 flex-1 grid-cols-1 gap-4 md:grid-cols-4 md:gap-x-8 md:gap-y-4">
|
||||
{/* Row 1: Account, Resource, Service, Region */}
|
||||
<EntityInfo
|
||||
cloudProvider={f.providerType}
|
||||
nameIcon={<Box className="size-4" />}
|
||||
entityAlias={f.providerAlias}
|
||||
entityId={f.providerUid}
|
||||
/>
|
||||
<EntityInfo
|
||||
nameIcon={<Container className="size-4" />}
|
||||
entityAlias={f.resourceGroup}
|
||||
entityId={f.resourceUid}
|
||||
idLabel="UID"
|
||||
/>
|
||||
<InfoField label="Service" variant="compact">
|
||||
{f.resourceService}
|
||||
</InfoField>
|
||||
<InfoField label="Region" variant="compact">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{getRegionFlag(f.resourceRegion) && (
|
||||
<span className="translate-y-px text-base leading-none">
|
||||
{getRegionFlag(f.resourceRegion)}
|
||||
</span>
|
||||
)}
|
||||
{f.resourceRegion}
|
||||
</span>
|
||||
</InfoField>
|
||||
|
||||
{/* Row 2: Dates */}
|
||||
<InfoField label="Last detected" variant="compact">
|
||||
<DateWithTime inline dateTime={f.updatedAt || "-"} />
|
||||
</InfoField>
|
||||
<InfoField label="First seen" variant="compact">
|
||||
<DateWithTime inline dateTime={f.firstSeenAt || "-"} />
|
||||
</InfoField>
|
||||
<InfoField label="Failing for" variant="compact">
|
||||
{getFailingForLabel(f.firstSeenAt) || "-"}
|
||||
</InfoField>
|
||||
<div className="hidden md:block" />
|
||||
|
||||
{/* Row 3: IDs */}
|
||||
<InfoField label="Check ID" variant="compact">
|
||||
<CodeSnippet
|
||||
value={checkMeta.checkId}
|
||||
transparent
|
||||
className="max-w-full text-sm"
|
||||
/>
|
||||
</InfoField>
|
||||
<InfoField label="Finding ID" variant="compact">
|
||||
<CodeSnippet
|
||||
value={f.id}
|
||||
transparent
|
||||
className="max-w-full text-sm"
|
||||
/>
|
||||
</InfoField>
|
||||
<InfoField label="Finding UID" variant="compact">
|
||||
<CodeSnippet
|
||||
value={f.uid}
|
||||
transparent
|
||||
className="max-w-full text-sm"
|
||||
/>
|
||||
</InfoField>
|
||||
</div>
|
||||
|
||||
{/* Actions button — fixed size, aligned with row 1 */}
|
||||
<div className="shrink-0">
|
||||
<ActionDropdown variant="bordered" 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)}
|
||||
/>
|
||||
<ActionDropdownItem
|
||||
icon={<JiraIcon size={20} />}
|
||||
label="Send to Jira"
|
||||
onSelect={() => setIsJiraModalOpen(true)}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
defaultValue="overview"
|
||||
className="mt-2 flex min-h-fit w-full flex-1 flex-col md:min-h-0"
|
||||
>
|
||||
<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>
|
||||
<TabsTrigger value="scans">Scans</TabsTrigger>
|
||||
<TabsTrigger value="events">Events</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">
|
||||
<Spinner 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>
|
||||
|
||||
{/* Scans Tab */}
|
||||
<TabsContent value="scans" className="flex flex-col gap-4">
|
||||
{f?.scan ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-text-neutral-secondary text-xs">
|
||||
Showing the latest scan that evaluated this finding
|
||||
</p>
|
||||
<Button variant="link" size="link-sm" asChild>
|
||||
<Link
|
||||
href={`/scans?id=${f.scan.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View scan
|
||||
<ExternalLink className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<InfoField label="Scan Name" variant="compact">
|
||||
{f.scan.name || "N/A"}
|
||||
</InfoField>
|
||||
<InfoField label="Resources Scanned" variant="compact">
|
||||
{f.scan.uniqueResourceCount}
|
||||
</InfoField>
|
||||
<InfoField label="Progress" variant="compact">
|
||||
{f.scan.progress}%
|
||||
</InfoField>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<InfoField label="Trigger" variant="compact">
|
||||
{f.scan.trigger}
|
||||
</InfoField>
|
||||
<InfoField label="State" variant="compact">
|
||||
{f.scan.state}
|
||||
</InfoField>
|
||||
<InfoField label="Duration" variant="compact">
|
||||
{formatDuration(f.scan.duration)}
|
||||
</InfoField>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<InfoField label="Started At" variant="compact">
|
||||
<DateWithTime inline dateTime={f.scan.startedAt || "-"} />
|
||||
</InfoField>
|
||||
<InfoField label="Completed At" variant="compact">
|
||||
<DateWithTime inline dateTime={f.scan.completedAt || "-"} />
|
||||
</InfoField>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<InfoField label="Launched At" variant="compact">
|
||||
<DateWithTime inline dateTime={f.scan.insertedAt || "-"} />
|
||||
</InfoField>
|
||||
{f.scan.scheduledAt && (
|
||||
<InfoField label="Scheduled At" variant="compact">
|
||||
<DateWithTime inline dateTime={f.scan.scheduledAt} />
|
||||
</InfoField>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-text-neutral-tertiary text-sm">
|
||||
Scan information is not available.
|
||||
</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Events Tab */}
|
||||
<TabsContent
|
||||
value="events"
|
||||
className="flex min-h-0 flex-1 flex-col gap-4"
|
||||
>
|
||||
<EventsTimeline
|
||||
resourceId={f?.resourceId}
|
||||
isAwsProvider={f?.providerType === "aws"}
|
||||
/>
|
||||
</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: "var(--gradient-lighthouse)",
|
||||
}}
|
||||
>
|
||||
<CircleArrowRight className="size-5" />
|
||||
View This Finding With Lighthouse AI
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OtherFindingRow({ finding }: { finding: ResourceDrawerFinding }) {
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
|
||||
|
||||
const findingUrl = `/findings?filter%5Bcheck_id__in%5D=${encodeURIComponent(finding.checkId)}&filter%5Bmuted%5D=include`;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!finding.isMuted && (
|
||||
<MuteFindingsModal
|
||||
isOpen={isMuteModalOpen}
|
||||
onOpenChange={setIsMuteModalOpen}
|
||||
findingIds={[finding.id]}
|
||||
/>
|
||||
)}
|
||||
<SendToJiraModal
|
||||
isOpen={isJiraModalOpen}
|
||||
onOpenChange={setIsJiraModalOpen}
|
||||
findingId={finding.id}
|
||||
findingTitle={finding.checkTitle}
|
||||
/>
|
||||
<TableRow
|
||||
className="cursor-pointer"
|
||||
onClick={() => window.open(findingUrl, "_blank", "noopener,noreferrer")}
|
||||
>
|
||||
<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">
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<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)}
|
||||
/>
|
||||
<ActionDropdownItem
|
||||
icon={<JiraIcon size={20} />}
|
||||
label="Send to Jira"
|
||||
onSelect={() => setIsJiraModalOpen(true)}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
</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,64 @@
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
|
||||
/**
|
||||
* Skeleton placeholder for the resource info grid in the detail drawer.
|
||||
* Mirrors the 4-column layout: EntityInfo × 2, InfoField × 2 per row,
|
||||
* plus the actions button.
|
||||
*/
|
||||
export function ResourceDetailSkeleton() {
|
||||
return (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="grid min-w-0 flex-1 grid-cols-1 gap-4 md:grid-cols-4 md:gap-x-8 md:gap-y-4">
|
||||
{/* Row 1: Account, Resource, Service, Region */}
|
||||
<EntityInfoSkeleton hasIcon />
|
||||
<EntityInfoSkeleton />
|
||||
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-20" />
|
||||
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-24" />
|
||||
|
||||
{/* Row 2: Last detected, First seen, Failing for */}
|
||||
<InfoFieldSkeleton labelWidth="w-20" valueWidth="w-32" />
|
||||
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-32" />
|
||||
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-16" />
|
||||
<div className="hidden md:block" />
|
||||
|
||||
{/* Row 3: Check ID, Finding ID, Finding UID */}
|
||||
<InfoFieldSkeleton labelWidth="w-14" valueWidth="w-36" />
|
||||
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-36" />
|
||||
<InfoFieldSkeleton labelWidth="w-20" valueWidth="w-36" />
|
||||
</div>
|
||||
|
||||
{/* Actions button */}
|
||||
<Skeleton className="size-11 shrink-0 rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityInfoSkeleton({ hasIcon = false }: { hasIcon?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
{hasIcon && <Skeleton className="size-9 shrink-0 rounded-md" />}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Skeleton className="size-4 rounded" />
|
||||
<Skeleton className="h-5 w-28 rounded" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-24 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoFieldSkeleton({
|
||||
labelWidth,
|
||||
valueWidth,
|
||||
}: {
|
||||
labelWidth: string;
|
||||
valueWidth: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Skeleton className={`h-3.5 ${labelWidth} rounded`} />
|
||||
<Skeleton className={`h-5 ${valueWidth} rounded`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"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;
|
||||
totalResourceCount?: number;
|
||||
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;
|
||||
/** Clear cache for current resource and re-fetch (e.g. after muting). */
|
||||
refetchCurrent: () => 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,
|
||||
totalResourceCount,
|
||||
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 fetchControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchFindings = async (resourceUid: string) => {
|
||||
// Abort any in-flight request to prevent stale data from out-of-order responses
|
||||
fetchControllerRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
fetchControllerRef.current = controller;
|
||||
|
||||
// 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 });
|
||||
|
||||
// Discard stale response if a newer request was started
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
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) {
|
||||
if (!controller.signal.aborted) {
|
||||
console.error("Error fetching findings for resource:", error);
|
||||
// Don't clear findings — keep previous data as fallback during navigation
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
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 refetchCurrent = () => {
|
||||
const resource = resources[currentIndex];
|
||||
if (!resource) return;
|
||||
cacheRef.current.delete(resource.resourceUid);
|
||||
setIsNavigating(true);
|
||||
fetchFindings(resource.resourceUid);
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
const total = totalResourceCount ?? resources.length;
|
||||
if (currentIndex >= total - 1) return;
|
||||
|
||||
// Pre-fetch more resources when nearing the end of loaded data
|
||||
if (currentIndex >= resources.length - 3) {
|
||||
onRequestMoreResources?.();
|
||||
}
|
||||
|
||||
// Navigate if the next resource is already loaded
|
||||
if (currentIndex < resources.length - 1) {
|
||||
navigateTo(currentIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
// 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: totalResourceCount ?? resources.length,
|
||||
currentFinding,
|
||||
otherFindings,
|
||||
allFindings: findings,
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
navigatePrev,
|
||||
navigateNext,
|
||||
refetchCurrent,
|
||||
};
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -5,8 +5,6 @@ import { Eye, Pencil, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { VerticalDotsIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownDangerZone,
|
||||
@@ -59,13 +57,7 @@ export function DataTableRowActions<InvitationProps>({
|
||||
</Modal>
|
||||
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<ActionDropdown
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" className="rounded-full">
|
||||
<VerticalDotsIcon className="text-slate-400" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ActionDropdown>
|
||||
<ActionDropdownItem
|
||||
icon={<Eye />}
|
||||
label="Check Details"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { getLighthouseModelIds } from "@/actions/lighthouse/lighthouse";
|
||||
import {
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "@/components/shadcn";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
import type { LighthouseProvider } from "@/types/lighthouse";
|
||||
|
||||
interface Model {
|
||||
@@ -61,6 +62,7 @@ interface ChatProps {
|
||||
providers: Provider[];
|
||||
defaultProviderId?: LighthouseProvider;
|
||||
defaultModelId?: string;
|
||||
initialPrompt?: string;
|
||||
}
|
||||
|
||||
interface SelectedModel {
|
||||
@@ -102,6 +104,7 @@ export const Chat = ({
|
||||
providers: initialProviders,
|
||||
defaultProviderId,
|
||||
defaultModelId,
|
||||
initialPrompt,
|
||||
}: ChatProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -143,12 +146,11 @@ export const Chat = ({
|
||||
selectedModelRef.current = selectedModel;
|
||||
|
||||
// Load models for all providers on mount
|
||||
useEffect(() => {
|
||||
useMountEffect(() => {
|
||||
initialProviders.forEach((provider) => {
|
||||
loadModelsForProvider(provider.id);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
});
|
||||
|
||||
// Load all models for a specific provider
|
||||
const loadModelsForProvider = async (providerType: LighthouseProvider) => {
|
||||
@@ -306,6 +308,15 @@ export const Chat = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-send initial prompt from URL (e.g., finding context from drawer)
|
||||
const initialPromptSentRef = useRef(false);
|
||||
useMountEffect(() => {
|
||||
if (initialPrompt && !initialPromptSentRef.current) {
|
||||
initialPromptSentRef.current = true;
|
||||
sendMessage({ text: initialPrompt });
|
||||
}
|
||||
});
|
||||
|
||||
// Handlers
|
||||
const handleNewChat = () => {
|
||||
setMessages([]);
|
||||
|
||||
@@ -5,8 +5,6 @@ import { Pencil, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { VerticalDotsIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownDangerZone,
|
||||
@@ -40,13 +38,7 @@ export function DataTableRowActions<ProviderProps>({
|
||||
</Modal>
|
||||
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<ActionDropdown
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" className="rounded-full">
|
||||
<VerticalDotsIcon className="text-slate-400" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ActionDropdown>
|
||||
<ActionDropdownItem
|
||||
icon={<Pencil />}
|
||||
label="Edit Account Group"
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/shadcn/select/select";
|
||||
import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon";
|
||||
import { ToastAction, useToast } from "@/components/ui";
|
||||
import { useOrgSetupStore } from "@/store/organizations/store";
|
||||
@@ -121,7 +121,7 @@ export function OrgLaunchScan({
|
||||
{isLaunching ? (
|
||||
<div className="flex min-h-[220px] items-center justify-center">
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<TreeSpinner className="size-6" />
|
||||
<Spinner className="size-6" />
|
||||
<p className="text-sm font-medium">Launching scans...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ import { WizardInputField } from "@/components/providers/workflow/forms/fields";
|
||||
import { Alert, AlertDescription } from "@/components/shadcn/alert";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { Checkbox } from "@/components/shadcn/checkbox/checkbox";
|
||||
import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import {
|
||||
@@ -284,7 +284,7 @@ export function OrgSetupForm({
|
||||
{setupPhase === ORG_SETUP_PHASE.ACCESS && isSubmitting && (
|
||||
<div className="flex min-h-[220px] items-center justify-center">
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<TreeSpinner className="size-6" />
|
||||
<Spinner className="size-6" />
|
||||
<p className="text-sm font-medium">Gathering AWS Accounts...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,13 +6,11 @@ import { useState } from "react";
|
||||
|
||||
import { updateOrganizationName } from "@/actions/organizations/organizations";
|
||||
import { updateProvider } from "@/actions/providers";
|
||||
import { VerticalDotsIcon } from "@/components/icons";
|
||||
import { ProviderWizardModal } from "@/components/providers/wizard";
|
||||
import {
|
||||
ORG_WIZARD_INTENT,
|
||||
OrgWizardInitialData,
|
||||
} from "@/components/providers/wizard/types";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownDangerZone,
|
||||
@@ -150,13 +148,7 @@ function OrgGroupDropdownActions({
|
||||
</Modal>
|
||||
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<ActionDropdown
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" className="rounded-full">
|
||||
<VerticalDotsIcon className="text-text-neutral-secondary" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ActionDropdown>
|
||||
{isOrgKind && (
|
||||
<>
|
||||
<ActionDropdownItem
|
||||
@@ -304,13 +296,7 @@ export function DataTableRowActions({
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<ActionDropdown
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" className="rounded-full">
|
||||
<VerticalDotsIcon className="text-text-neutral-secondary" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ActionDropdown>
|
||||
<ActionDropdownItem
|
||||
icon={<Rocket />}
|
||||
label={loading ? "Testing..." : `Test Connection${bulkCount}`}
|
||||
@@ -399,13 +385,7 @@ export function DataTableRowActions({
|
||||
/>
|
||||
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<ActionDropdown
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" className="rounded-full">
|
||||
<VerticalDotsIcon className="text-text-neutral-secondary" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ActionDropdown>
|
||||
<ActionDropdownItem
|
||||
icon={<Pencil />}
|
||||
label="Edit Provider Alias"
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/shadcn/select/select";
|
||||
import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon";
|
||||
import { ToastAction, useToast } from "@/components/ui";
|
||||
import { useProviderWizardStore } from "@/store/provider-wizard/store";
|
||||
@@ -111,7 +111,7 @@ export function LaunchStep({
|
||||
return (
|
||||
<div className="flex min-h-[320px] items-center justify-center">
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<TreeSpinner className="size-6" />
|
||||
<Spinner className="size-6" />
|
||||
<p className="text-sm font-medium">Launching scans...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { AlertTriangle, Eye, MoreVertical } from "lucide-react";
|
||||
import { AlertTriangle, Eye } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
@@ -87,18 +87,7 @@ const ResourceRowActions = ({ row }: { row: { original: ResourceProps } }) => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-end">
|
||||
<ActionDropdown
|
||||
trigger={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Resource actions"
|
||||
className="hover:bg-bg-neutral-tertiary rounded-md p-1 transition-colors"
|
||||
>
|
||||
<MoreVertical className="text-text-neutral-secondary size-5" />
|
||||
</button>
|
||||
}
|
||||
ariaLabel="Resource actions"
|
||||
>
|
||||
<ActionDropdown ariaLabel="Resource actions">
|
||||
<ActionDropdownItem
|
||||
icon={<Eye className="size-5" />}
|
||||
label="View Details"
|
||||
|
||||
@@ -5,8 +5,6 @@ import { Pencil, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { VerticalDotsIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownDangerZone,
|
||||
@@ -36,13 +34,7 @@ export function DataTableRowActions<RoleProps>({
|
||||
<DeleteRoleForm roleId={roleId} setIsOpen={setIsDeleteOpen} />
|
||||
</Modal>
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<ActionDropdown
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" className="rounded-full">
|
||||
<VerticalDotsIcon className="text-slate-400" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ActionDropdown>
|
||||
<ActionDropdownItem
|
||||
icon={<Pencil />}
|
||||
label="Edit Role"
|
||||
|
||||
@@ -4,26 +4,13 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
|
||||
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
|
||||
import { DateWithTime, EntityInfo, InfoField } from "@/components/ui/entities";
|
||||
import { StatusBadge } from "@/components/ui/table/status-badge";
|
||||
import { formatDuration } from "@/lib/date-utils";
|
||||
import { ProviderProps, ProviderType, ScanProps, TaskDetails } from "@/types";
|
||||
|
||||
const renderValue = (value: string | null | undefined) => {
|
||||
return value && value.trim() !== "" ? value : "-";
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
|
||||
const parts = [];
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0) parts.push(`${minutes}m`);
|
||||
if (remainingSeconds > 0 || parts.length === 0)
|
||||
parts.push(`${remainingSeconds}s`);
|
||||
|
||||
return parts.join(" ");
|
||||
};
|
||||
|
||||
export const ScanDetail = ({
|
||||
scanDetails,
|
||||
}: {
|
||||
|
||||
@@ -4,8 +4,6 @@ import { Row } from "@tanstack/react-table";
|
||||
import { Download, Pencil } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { VerticalDotsIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownItem,
|
||||
@@ -44,13 +42,7 @@ export function DataTableRowActions<ScanProps>({
|
||||
</Modal>
|
||||
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<ActionDropdown
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" className="rounded-full">
|
||||
<VerticalDotsIcon className="text-slate-400" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ActionDropdown>
|
||||
<ActionDropdownItem
|
||||
icon={<Download />}
|
||||
label="Download .zip"
|
||||
|
||||
@@ -61,7 +61,7 @@ function Checkbox({
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
{indeterminate ? (
|
||||
{indeterminate || checked === "indeterminate" ? (
|
||||
<MinusIcon className={sizeStyles.icon} />
|
||||
) : (
|
||||
<CheckIcon className={sizeStyles.icon} />
|
||||
|
||||
@@ -62,6 +62,8 @@ function DialogContent({
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { EllipsisVertical } from "lucide-react";
|
||||
import { ComponentProps, ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -13,9 +13,19 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "./dropdown";
|
||||
|
||||
const ACTION_TRIGGER_STYLES = {
|
||||
table: "hover:bg-bg-neutral-tertiary rounded-full p-1 transition-colors",
|
||||
bordered:
|
||||
"hover:bg-bg-neutral-tertiary rounded-full border border-text-neutral-secondary p-2 transition-colors",
|
||||
} as const;
|
||||
|
||||
type ActionDropdownVariant = keyof typeof ACTION_TRIGGER_STYLES;
|
||||
|
||||
interface ActionDropdownProps {
|
||||
/** The dropdown trigger element. Defaults to a vertical dots icon button */
|
||||
trigger?: ReactNode;
|
||||
/** Trigger style variant. "table" = no border, "bordered" = circular border */
|
||||
variant?: ActionDropdownVariant;
|
||||
/** Alignment of the dropdown content */
|
||||
align?: "start" | "center" | "end";
|
||||
/** Additional className for the content */
|
||||
@@ -27,6 +37,7 @@ interface ActionDropdownProps {
|
||||
|
||||
export function ActionDropdown({
|
||||
trigger,
|
||||
variant = "table",
|
||||
align = "end",
|
||||
className,
|
||||
ariaLabel = "Open actions menu",
|
||||
@@ -39,9 +50,9 @@ export function ActionDropdown({
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
className="hover:bg-bg-neutral-tertiary rounded-md p-1 transition-colors"
|
||||
className={ACTION_TRIGGER_STYLES[variant]}
|
||||
>
|
||||
<MoreHorizontal className="text-text-neutral-secondary size-5" />
|
||||
<EllipsisVertical className="text-text-neutral-secondary size-6" />
|
||||
</button>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -11,6 +11,7 @@ export const INFO_FIELD_VARIANTS = {
|
||||
default: "default",
|
||||
simple: "simple",
|
||||
transparent: "transparent",
|
||||
compact: "compact",
|
||||
} as const;
|
||||
|
||||
type InfoFieldVariant =
|
||||
@@ -61,17 +62,21 @@ export function InfoField({
|
||||
);
|
||||
}
|
||||
|
||||
const isCompact = variant === "compact";
|
||||
|
||||
const labelClassName = isCompact
|
||||
? "text-text-neutral-secondary text-[10px] whitespace-nowrap"
|
||||
: "text-text-neutral-tertiary text-xs font-bold";
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1", className)}>
|
||||
<span className="text-text-neutral-tertiary text-xs font-bold">
|
||||
{labelContent}
|
||||
</span>
|
||||
<span className={labelClassName}>{labelContent}</span>
|
||||
|
||||
{variant === "simple" ? (
|
||||
<div className="text-text-neutral-primary text-sm break-all">
|
||||
{children}
|
||||
</div>
|
||||
) : variant === "transparent" ? (
|
||||
) : variant === "transparent" || variant === "compact" ? (
|
||||
<div className="text-text-neutral-primary text-sm">{children}</div>
|
||||
) : (
|
||||
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary text-text-neutral-primary rounded-lg border px-3 py-2 text-sm">
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SpinnerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spinner component - a circular loading indicator.
|
||||
*
|
||||
* Features:
|
||||
* - 20x20 (size-5) default size
|
||||
* - 2.5px stroke for good visibility
|
||||
* - Uses button-primary color
|
||||
* - Smooth rotation animation
|
||||
* - Accepts className to override size (e.g. "size-6", "size-4")
|
||||
*/
|
||||
export function Spinner({ className }: SpinnerProps) {
|
||||
return (
|
||||
<svg
|
||||
className={cn("size-5 shrink-0 animate-spin", className)}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Loading"
|
||||
>
|
||||
{/* Background track */}
|
||||
<circle
|
||||
cx="10"
|
||||
cy="10"
|
||||
r="7.5"
|
||||
className="stroke-button-primary/20"
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
/>
|
||||
{/* Animated arc */}
|
||||
<circle
|
||||
cx="10"
|
||||
cy="10"
|
||||
r="7.5"
|
||||
className="stroke-button-primary"
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="47.12"
|
||||
strokeDashoffset="35.34"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1,50 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TreeSpinnerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TreeSpinner component - a circular loading indicator for tree nodes.
|
||||
*
|
||||
* Features:
|
||||
* - 20x20 (size-5) default size to match checkbox sm
|
||||
* - 2.5px stroke for good visibility
|
||||
* - Uses button-primary color
|
||||
* - Smooth rotation animation
|
||||
*/
|
||||
export function TreeSpinner({ className }: TreeSpinnerProps) {
|
||||
return (
|
||||
<svg
|
||||
className={cn("size-5 shrink-0 animate-spin", className)}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Loading"
|
||||
>
|
||||
{/* Background track */}
|
||||
<circle
|
||||
cx="10"
|
||||
cy="10"
|
||||
r="7.5"
|
||||
className="stroke-button-primary/20"
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
/>
|
||||
{/* Animated arc */}
|
||||
<circle
|
||||
cx="10"
|
||||
cy="10"
|
||||
r="7.5"
|
||||
className="stroke-button-primary"
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="47.12"
|
||||
strokeDashoffset="35.34"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
// Re-export Spinner as TreeSpinner for tree-view internal usage.
|
||||
// New code should import Spinner from "@/components/shadcn/spinner/spinner".
|
||||
export { Spinner as TreeSpinner } from "../spinner/spinner";
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Download,
|
||||
Loader2,
|
||||
Server,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
Checkbox,
|
||||
InfoField,
|
||||
} from "@/components/shadcn";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ResourceEventProps } from "@/types";
|
||||
@@ -128,7 +128,7 @@ export const EventsTimeline = ({
|
||||
if (isPending && !hasFetched) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12">
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
<Spinner className="size-6" />
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Fetching CloudTrail events...
|
||||
</p>
|
||||
@@ -181,7 +181,7 @@ export const EventsTimeline = ({
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{isPending && <Loader2 className="size-4 animate-spin" />}
|
||||
{isPending && <Spinner className="size-4" />}
|
||||
<span className="text-text-neutral-tertiary text-xs">
|
||||
{events.length} event{events.length !== 1 && "s"}
|
||||
</span>
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -37,7 +37,9 @@ export const DateWithTime = ({
|
||||
<div
|
||||
className={cn(
|
||||
"gap-1",
|
||||
inline ? "inline-flex flex-row items-center" : "flex flex-col",
|
||||
inline
|
||||
? "inline-flex flex-row flex-wrap items-center"
|
||||
: "flex flex-col",
|
||||
)}
|
||||
>
|
||||
<span className="text-text-neutral-primary text-sm whitespace-nowrap">
|
||||
|
||||
@@ -15,9 +15,13 @@ import { getProviderLogo } from "./get-provider-logo";
|
||||
interface EntityInfoProps {
|
||||
cloudProvider?: ProviderType;
|
||||
icon?: ReactNode;
|
||||
/** Small icon rendered inline before the entity alias text */
|
||||
nameIcon?: ReactNode;
|
||||
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;
|
||||
@@ -30,9 +34,11 @@ interface EntityInfoProps {
|
||||
export const EntityInfo = ({
|
||||
cloudProvider,
|
||||
icon,
|
||||
nameIcon,
|
||||
entityAlias,
|
||||
entityId,
|
||||
badge,
|
||||
idLabel = "UID",
|
||||
showCopyAction = true,
|
||||
}: EntityInfoProps) => {
|
||||
const canCopy = Boolean(entityId && showCopyAction);
|
||||
@@ -45,6 +51,11 @@ export const EntityInfo = ({
|
||||
{renderedIcon && <div className="shrink-0">{renderedIcon}</div>}
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{nameIcon && (
|
||||
<span className="text-text-neutral-tertiary shrink-0">
|
||||
{nameIcon}
|
||||
</span>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="truncate font-medium">
|
||||
@@ -64,7 +75,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}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { LoaderCircleIcon, SearchIcon } from "lucide-react";
|
||||
import { LoaderCircleIcon, SearchIcon, X } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
import { Input } from "@/components/shadcn/input/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -21,12 +27,17 @@ interface DataTableSearchProps {
|
||||
*/
|
||||
controlledValue?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
/** Badge shown inside the search input (e.g., active drill-down group title) */
|
||||
badge?: { label: string; onDismiss: () => void };
|
||||
}
|
||||
|
||||
export const DataTableSearch = ({
|
||||
paramPrefix = "",
|
||||
controlledValue,
|
||||
onSearchChange,
|
||||
placeholder = "Search...",
|
||||
badge,
|
||||
}: DataTableSearchProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
@@ -47,6 +58,9 @@ export const DataTableSearch = ({
|
||||
// For display: use displayValue in controlled mode (for responsive typing), internalValue otherwise
|
||||
const value = isControlled ? displayValue : internalValue;
|
||||
|
||||
// Force expanded when badge is present
|
||||
const hasBadge = !!badge;
|
||||
|
||||
// Sync displayValue when controlledValue changes externally (e.g., clear filters)
|
||||
useEffect(() => {
|
||||
if (isControlled) {
|
||||
@@ -58,8 +72,8 @@ export const DataTableSearch = ({
|
||||
const searchParam = paramPrefix ? `${paramPrefix}Search` : "filter[search]";
|
||||
const pageParam = paramPrefix ? `${paramPrefix}Page` : "page";
|
||||
|
||||
// Keep expanded if there's a value or input is focused
|
||||
const shouldStayExpanded = value.length > 0 || isFocused;
|
||||
// Keep expanded if there's a value or input is focused or badge is present
|
||||
const shouldStayExpanded = value.length > 0 || isFocused || hasBadge;
|
||||
|
||||
// Sync with URL on mount (only for uncontrolled mode)
|
||||
useEffect(() => {
|
||||
@@ -152,7 +166,7 @@ export const DataTableSearch = ({
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocused(false);
|
||||
if (!value) {
|
||||
if (!value && !hasBadge) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
};
|
||||
@@ -165,11 +179,13 @@ export const DataTableSearch = ({
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const effectiveExpanded = isExpanded || hasBadge;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center transition-all duration-300 ease-in-out",
|
||||
isExpanded ? "w-64" : "w-10",
|
||||
effectiveExpanded ? (hasBadge ? "w-[28rem]" : "w-64") : "w-10",
|
||||
)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
@@ -180,39 +196,73 @@ export const DataTableSearch = ({
|
||||
onClick={handleIconClick}
|
||||
className={cn(
|
||||
"border-border-neutral-tertiary bg-bg-neutral-tertiary absolute left-0 flex size-10 items-center justify-center rounded-md border transition-opacity duration-200",
|
||||
isExpanded ? "pointer-events-none opacity-0" : "opacity-100",
|
||||
effectiveExpanded ? "pointer-events-none opacity-0" : "opacity-100",
|
||||
)}
|
||||
aria-label="Open search"
|
||||
>
|
||||
<SearchIcon className="text-text-neutral-tertiary size-4" />
|
||||
</button>
|
||||
|
||||
{/* Expanded state - full input */}
|
||||
{/* Expanded state - full input with optional badge */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full transition-opacity duration-200",
|
||||
isExpanded ? "opacity-100" : "pointer-events-none opacity-0",
|
||||
effectiveExpanded ? "opacity-100" : "pointer-events-none opacity-0",
|
||||
)}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<SearchIcon className="text-text-neutral-tertiary size-4" />
|
||||
</div>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
type="search"
|
||||
placeholder="Search..."
|
||||
value={value}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
className="border-border-neutral-tertiary bg-bg-neutral-tertiary focus:border-border-input-primary-pressed pr-9 pl-9 focus:ring-0 focus:ring-offset-0 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none [&::-webkit-search-results-button]:appearance-none [&::-webkit-search-results-decoration]:appearance-none"
|
||||
/>
|
||||
{isLoading && (
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<LoaderCircleIcon className="text-text-neutral-tertiary size-4 animate-spin" />
|
||||
<div
|
||||
className={cn(
|
||||
"border-border-neutral-tertiary bg-bg-neutral-tertiary hover:bg-bg-neutral-secondary flex items-center gap-1.5 rounded-md border transition-colors",
|
||||
isFocused && "border-border-input-primary-pressed",
|
||||
)}
|
||||
>
|
||||
<div className="flex shrink-0 items-center pl-3">
|
||||
<SearchIcon className="text-text-neutral-tertiary size-4" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasBadge && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="tag"
|
||||
className="max-w-[200px] shrink-0 cursor-default gap-1 truncate"
|
||||
>
|
||||
<span className="truncate">{badge.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss filter"
|
||||
className="hover:text-text-neutral-primary ml-0.5 shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
badge.onDismiss();
|
||||
}}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{badge.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
type="search"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
className="h-9 min-w-0 flex-1 border-0 bg-transparent pr-9 shadow-none hover:bg-transparent focus:border-0 focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none [&::-webkit-search-results-button]:appearance-none [&::-webkit-search-results-decoration]:appearance-none"
|
||||
/>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex shrink-0 items-center pr-3">
|
||||
<LoaderCircleIcon className="text-text-neutral-tertiary size-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
Table,
|
||||
@@ -96,6 +96,12 @@ 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;
|
||||
/** Render additional content after each row (e.g., inline expansion) */
|
||||
renderAfterRow?: (row: Row<TData>) => React.ReactNode;
|
||||
/** Badge shown inside the search input (e.g., active drill-down group) */
|
||||
searchBadge?: { label: string; onDismiss: () => void };
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
@@ -121,6 +127,9 @@ export function DataTable<TData, TValue>({
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
isLoading = false,
|
||||
searchPlaceholder,
|
||||
renderAfterRow,
|
||||
searchBadge,
|
||||
}: DataTableProviderProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
@@ -213,6 +222,8 @@ export function DataTable<TData, TValue>({
|
||||
paramPrefix={paramPrefix}
|
||||
controlledValue={controlledSearch}
|
||||
onSearchChange={onSearchChange}
|
||||
placeholder={searchPlaceholder}
|
||||
badge={searchBadge}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -262,19 +273,19 @@ export function DataTable<TData, TValue>({
|
||||
isSomeSelected={row.getIsSomeSelected()}
|
||||
/>
|
||||
) : (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
<Fragment key={row.id}>
|
||||
<TableRow data-state={row.getIsSelected() && "selected"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
{renderAfterRow?.(row)}
|
||||
</Fragment>
|
||||
),
|
||||
)
|
||||
) : (
|
||||
|
||||
@@ -13,7 +13,7 @@ const Table = forwardRef<HTMLTableElement, HTMLAttributes<HTMLTableElement>>(
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"w-full caption-bottom border-separate border-spacing-y-1 text-sm",
|
||||
"w-full caption-bottom border-separate border-spacing-y-4 text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -39,7 +39,11 @@ const TableBody = forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn("", className)} {...props} />
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&>tr:last-child>td]:after:hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
@@ -105,7 +109,8 @@ const TableCell = forwardRef<
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-1.5 py-2 align-middle first:pl-3 last:pr-3 [&:has([role=checkbox])]:pr-0",
|
||||
"relative px-1.5 py-2 align-middle first:pl-3 last:pr-3 [&:has([role=checkbox])]:pr-0",
|
||||
"after:bg-border-input-primary after:absolute after:right-0 after:-bottom-2 after:left-0 after:h-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
import { Row } from "@tanstack/react-table";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
import { VerticalDotsIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownDangerZone,
|
||||
@@ -31,13 +29,7 @@ export function DataTableRowActions({
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<ActionDropdown
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" className="rounded-full">
|
||||
<VerticalDotsIcon className="text-slate-400" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ActionDropdown>
|
||||
<ActionDropdownItem
|
||||
icon={<Pencil />}
|
||||
label="Edit API Key"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
import { FALLBACK_VALUES } from "./constants";
|
||||
import {
|
||||
API_KEY_STATUS,
|
||||
@@ -31,10 +29,7 @@ export const getStatusLabel = (status: ApiKeyStatus): string => {
|
||||
return labelMap[status] || FALLBACK_VALUES.UNKNOWN;
|
||||
};
|
||||
|
||||
export const formatRelativeTime = (date: string | null): string => {
|
||||
if (!date) return FALLBACK_VALUES.NEVER;
|
||||
return formatDistanceToNow(new Date(date), { addSuffix: true });
|
||||
};
|
||||
export { formatRelativeTime } from "@/lib/date-utils";
|
||||
|
||||
export const calculateExpiryDate = (days: number): string => {
|
||||
const expiresAt = new Date();
|
||||
|
||||
@@ -4,8 +4,6 @@ import { Row } from "@tanstack/react-table";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { VerticalDotsIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownDangerZone,
|
||||
@@ -59,13 +57,7 @@ export function DataTableRowActions<UserProps>({
|
||||
</Modal>
|
||||
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<ActionDropdown
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" className="rounded-full">
|
||||
<VerticalDotsIcon className="text-slate-400" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ActionDropdown>
|
||||
<ActionDropdownItem
|
||||
icon={<Pencil />}
|
||||
label="Edit User"
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from "./use-auth";
|
||||
export * from "./use-credentials-form";
|
||||
export * from "./use-form-server-errors";
|
||||
export * from "./use-local-storage";
|
||||
export * from "./use-mount-effect";
|
||||
export * from "./use-related-filters";
|
||||
export * from "./use-scroll-hint";
|
||||
export * from "./use-sidebar";
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useInfiniteResources } from "./use-infinite-resources";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IntersectionObserver mock (jsdom doesn't provide one)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type IntersectionCallback = (entries: IntersectionObserverEntry[]) => void;
|
||||
|
||||
/** Stores the latest observer callback so tests can trigger intersections. */
|
||||
let latestObserverCallback: IntersectionCallback | null = null;
|
||||
|
||||
class MockIntersectionObserver {
|
||||
callback: IntersectionCallback;
|
||||
constructor(callback: IntersectionCallback) {
|
||||
this.callback = callback;
|
||||
latestObserverCallback = callback;
|
||||
}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {
|
||||
if (latestObserverCallback === this.callback) {
|
||||
latestObserverCallback = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
|
||||
|
||||
/** Simulate the sentinel becoming visible in the scroll container. */
|
||||
function triggerIntersection() {
|
||||
latestObserverCallback?.([
|
||||
{ isIntersecting: true } as IntersectionObserverEntry,
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const findingGroupActionsMock = vi.hoisted(() => ({
|
||||
getLatestFindingGroupResources: vi.fn(),
|
||||
getFindingGroupResources: vi.fn(),
|
||||
adaptFindingGroupResourcesResponse: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/finding-groups", () => findingGroupActionsMock);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeApiResponse(
|
||||
resources: { id: string }[],
|
||||
{ pages = 1 }: { pages?: number } = {},
|
||||
) {
|
||||
return {
|
||||
data: resources,
|
||||
meta: { pagination: { pages } },
|
||||
};
|
||||
}
|
||||
|
||||
function fakeResource(id: string) {
|
||||
return {
|
||||
findingId: id,
|
||||
resourceUid: `uid-${id}`,
|
||||
resourceName: `Resource ${id}`,
|
||||
status: "FAIL",
|
||||
severity: "high",
|
||||
isMuted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultOptions(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
checkId: "check_1",
|
||||
hasDateOrScanFilter: false,
|
||||
filters: {},
|
||||
onSetResources: vi.fn(),
|
||||
onAppendResources: vi.fn(),
|
||||
onSetLoading: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Flush all pending microtasks (awaits in fetchPage). */
|
||||
async function flushAsync() {
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useInfiniteResources", () => {
|
||||
beforeEach(() => {
|
||||
for (const mockFn of Object.values(findingGroupActionsMock)) {
|
||||
mockFn.mockReset();
|
||||
}
|
||||
});
|
||||
|
||||
describe("when mounting", () => {
|
||||
it("should fetch page 1 and deliver resources via onSetResources", async () => {
|
||||
// Given
|
||||
const apiResponse = makeApiResponse([{ id: "r1" }, { id: "r2" }], {
|
||||
pages: 1,
|
||||
});
|
||||
const adapted = [fakeResource("r1"), fakeResource("r2")];
|
||||
|
||||
findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue(
|
||||
apiResponse,
|
||||
);
|
||||
findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
|
||||
adapted,
|
||||
);
|
||||
|
||||
const onSetResources = vi.fn();
|
||||
const onSetLoading = vi.fn();
|
||||
|
||||
// When
|
||||
renderHook(() =>
|
||||
useInfiniteResources(defaultOptions({ onSetResources, onSetLoading })),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
// Then
|
||||
expect(
|
||||
findingGroupActionsMock.getLatestFindingGroupResources,
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
checkId: "check_1",
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
}),
|
||||
);
|
||||
expect(onSetResources).toHaveBeenCalledWith(adapted, false);
|
||||
});
|
||||
|
||||
it("should use getFindingGroupResources when hasDateOrScanFilter is true", async () => {
|
||||
// Given
|
||||
const apiResponse = makeApiResponse([], { pages: 1 });
|
||||
findingGroupActionsMock.getFindingGroupResources.mockResolvedValue(
|
||||
apiResponse,
|
||||
);
|
||||
findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
|
||||
[],
|
||||
);
|
||||
|
||||
// When
|
||||
renderHook(() =>
|
||||
useInfiniteResources(defaultOptions({ hasDateOrScanFilter: true })),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
// Then
|
||||
expect(
|
||||
findingGroupActionsMock.getFindingGroupResources,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
findingGroupActionsMock.getLatestFindingGroupResources,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when all resources fit in one page", () => {
|
||||
it("should not fetch page 2 after page 1 completes", async () => {
|
||||
// Given — API returns 4 resources, 1 page total
|
||||
const apiResponse = makeApiResponse(
|
||||
[{ id: "r1" }, { id: "r2" }, { id: "r3" }, { id: "r4" }],
|
||||
{ pages: 1 },
|
||||
);
|
||||
findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue(
|
||||
apiResponse,
|
||||
);
|
||||
findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
|
||||
[
|
||||
fakeResource("r1"),
|
||||
fakeResource("r2"),
|
||||
fakeResource("r3"),
|
||||
fakeResource("r4"),
|
||||
],
|
||||
);
|
||||
|
||||
// When
|
||||
const { result } = renderHook(() =>
|
||||
useInfiniteResources(defaultOptions()),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
// Attach sentinel so observer is created
|
||||
const sentinel = document.createElement("div");
|
||||
act(() => {
|
||||
result.current.sentinelRef(sentinel);
|
||||
});
|
||||
|
||||
// Simulate observer firing (sentinel visible after page 1 loaded)
|
||||
act(() => {
|
||||
triggerIntersection();
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
// Then — only page 1 was fetched, never page 2
|
||||
const calls =
|
||||
findingGroupActionsMock.getLatestFindingGroupResources.mock.calls;
|
||||
const pageNumbers = calls.map((c: { page: number }[]) => c[0].page);
|
||||
expect(pageNumbers.every((p: number) => p === 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when aborted fetch races with active fetch", () => {
|
||||
it("should not reset isLoading when an aborted fetch resolves", async () => {
|
||||
// Given — simulate the Strict Mode race condition:
|
||||
// fetch1 starts, gets aborted, fetch2 starts, fetch1's finally runs
|
||||
const onSetResources = vi.fn();
|
||||
const onSetLoading = vi.fn();
|
||||
|
||||
// fetch1 resolves slowly (after abort)
|
||||
let resolveFetch1: (v: unknown) => void;
|
||||
const fetch1Promise = new Promise((r) => {
|
||||
resolveFetch1 = r;
|
||||
});
|
||||
|
||||
// fetch2 resolves normally
|
||||
const apiResponse = makeApiResponse([{ id: "r1" }], { pages: 1 });
|
||||
const adapted = [fakeResource("r1")];
|
||||
|
||||
let callCount = 0;
|
||||
findingGroupActionsMock.getLatestFindingGroupResources.mockImplementation(
|
||||
() => {
|
||||
callCount++;
|
||||
if (callCount === 1) return fetch1Promise;
|
||||
return Promise.resolve(apiResponse);
|
||||
},
|
||||
);
|
||||
findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
|
||||
adapted,
|
||||
);
|
||||
|
||||
// When — mount, abort (simulating cleanup), remount
|
||||
const { unmount } = renderHook(() =>
|
||||
useInfiniteResources(defaultOptions({ onSetResources, onSetLoading })),
|
||||
);
|
||||
|
||||
// Simulate Strict Mode: unmount triggers abort
|
||||
unmount();
|
||||
|
||||
// Fetch1 resolves AFTER abort — its finally should NOT reset isLoading
|
||||
await act(async () => {
|
||||
resolveFetch1!(apiResponse);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
|
||||
// Then — onSetResources should NOT have been called by the aborted fetch
|
||||
// (the signal.aborted check returns early)
|
||||
expect(onSetResources).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when sentinel triggers next page", () => {
|
||||
it("should fetch page 2 via onAppendResources when hasMore is true", async () => {
|
||||
// Given — page 1 has more pages
|
||||
const page1Response = makeApiResponse(
|
||||
Array.from({ length: 10 }, (_, i) => ({ id: `r${i}` })),
|
||||
{ pages: 3 },
|
||||
);
|
||||
const page1Adapted = Array.from({ length: 10 }, (_, i) =>
|
||||
fakeResource(`r${i}`),
|
||||
);
|
||||
|
||||
const page2Response = makeApiResponse(
|
||||
Array.from({ length: 10 }, (_, i) => ({ id: `r${10 + i}` })),
|
||||
{ pages: 3 },
|
||||
);
|
||||
const page2Adapted = Array.from({ length: 10 }, (_, i) =>
|
||||
fakeResource(`r${10 + i}`),
|
||||
);
|
||||
|
||||
findingGroupActionsMock.getLatestFindingGroupResources
|
||||
.mockResolvedValueOnce(page1Response)
|
||||
.mockResolvedValueOnce(page2Response);
|
||||
findingGroupActionsMock.adaptFindingGroupResourcesResponse
|
||||
.mockReturnValueOnce(page1Adapted)
|
||||
.mockReturnValueOnce(page2Adapted);
|
||||
|
||||
const onSetResources = vi.fn();
|
||||
const onAppendResources = vi.fn();
|
||||
|
||||
// When — mount and wait for page 1
|
||||
const { result } = renderHook(() =>
|
||||
useInfiniteResources(
|
||||
defaultOptions({ onSetResources, onAppendResources }),
|
||||
),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
expect(onSetResources).toHaveBeenCalledWith(page1Adapted, true);
|
||||
|
||||
// Attach sentinel and simulate intersection → triggers page 2
|
||||
const sentinel = document.createElement("div");
|
||||
act(() => {
|
||||
result.current.sentinelRef(sentinel);
|
||||
});
|
||||
act(() => {
|
||||
triggerIntersection();
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
// Then
|
||||
expect(onAppendResources).toHaveBeenCalledWith(page2Adapted, true);
|
||||
expect(
|
||||
findingGroupActionsMock.getLatestFindingGroupResources,
|
||||
).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when refresh is called", () => {
|
||||
it("should re-fetch page 1 and deliver via onSetResources", async () => {
|
||||
// Given
|
||||
const apiResponse = makeApiResponse([{ id: "r1" }], { pages: 1 });
|
||||
const adapted = [fakeResource("r1")];
|
||||
|
||||
findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue(
|
||||
apiResponse,
|
||||
);
|
||||
findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
|
||||
adapted,
|
||||
);
|
||||
|
||||
const onSetResources = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useInfiniteResources(defaultOptions({ onSetResources })),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
expect(onSetResources).toHaveBeenCalledTimes(1);
|
||||
|
||||
// When — refresh (e.g. after muting)
|
||||
act(() => {
|
||||
result.current.refresh();
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
// Then — page 1 fetched again
|
||||
expect(onSetResources).toHaveBeenCalledTimes(2);
|
||||
const calls =
|
||||
findingGroupActionsMock.getLatestFindingGroupResources.mock.calls;
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0][0].page).toBe(1);
|
||||
expect(calls[1][0].page).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when filters include search params", () => {
|
||||
it("should pass filters to the fetch function", async () => {
|
||||
// Given
|
||||
const apiResponse = makeApiResponse([], { pages: 1 });
|
||||
findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue(
|
||||
apiResponse,
|
||||
);
|
||||
findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
|
||||
[],
|
||||
);
|
||||
|
||||
const filters = {
|
||||
"filter[name__icontains]": "my-resource",
|
||||
"filter[severity__in]": "high",
|
||||
};
|
||||
|
||||
// When
|
||||
renderHook(() => useInfiniteResources(defaultOptions({ filters })));
|
||||
await flushAsync();
|
||||
|
||||
// Then
|
||||
expect(
|
||||
findingGroupActionsMock.getLatestFindingGroupResources,
|
||||
).toHaveBeenCalledWith(expect.objectContaining({ filters }));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
|
||||
import {
|
||||
adaptFindingGroupResourcesResponse,
|
||||
getFindingGroupResources,
|
||||
getLatestFindingGroupResources,
|
||||
} from "@/actions/finding-groups";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
import { FindingResourceRow } from "@/types";
|
||||
|
||||
const RESOURCES_PAGE_SIZE = 10;
|
||||
|
||||
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;
|
||||
/** Scroll container element for IntersectionObserver root. Defaults to viewport. */
|
||||
scrollContainerRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
interface UseInfiniteResourcesReturn {
|
||||
sentinelRef: (node: HTMLDivElement | null) => void;
|
||||
/** Reset pagination and re-fetch page 1 (e.g. after muting). */
|
||||
refresh: () => void;
|
||||
/** Imperatively load the next page (e.g. from drawer navigation). */
|
||||
loadMore: () => 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. The parent component remounts this
|
||||
* hook via key-prop when checkId or filters change.
|
||||
*/
|
||||
export function useInfiniteResources({
|
||||
checkId,
|
||||
hasDateOrScanFilter,
|
||||
filters,
|
||||
onSetResources,
|
||||
onAppendResources,
|
||||
onSetLoading,
|
||||
scrollContainerRef,
|
||||
}: UseInfiniteResourcesOptions): UseInfiniteResourcesReturn {
|
||||
// All mutable state in refs to break dependency chains
|
||||
const pageRef = useRef(1);
|
||||
const hasMoreRef = useRef(true);
|
||||
// Start as `true` to block the IntersectionObserver from calling loadNextPage
|
||||
// before the initial fetch runs. Ref callbacks fire during commit (sync),
|
||||
// but useMountEffect fires after paint — the observer can sneak in between.
|
||||
const isLoadingRef = useRef(true);
|
||||
const currentCheckIdRef = useRef(checkId);
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
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;
|
||||
|
||||
async function fetchPage(
|
||||
page: number,
|
||||
append: boolean,
|
||||
forCheckId: string,
|
||||
signal: AbortSignal,
|
||||
) {
|
||||
if (isLoadingRef.current || signal.aborted) 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 aborted (e.g. Strict Mode remount)
|
||||
if (signal.aborted) {
|
||||
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) {
|
||||
if (!signal.aborted) {
|
||||
console.error("Error fetching resources:", error);
|
||||
onSetLoadingRef.current(false);
|
||||
}
|
||||
} finally {
|
||||
// Only release the loading guard if this fetch wasn't aborted.
|
||||
// An aborted fetch (e.g. Strict Mode cleanup) must NOT reset the flag
|
||||
// while a subsequent fetch from the remount is still in flight.
|
||||
if (!signal.aborted) {
|
||||
isLoadingRef.current = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch first page on mount — parent remounts via key-prop on checkId/filter changes
|
||||
useMountEffect(() => {
|
||||
const controller = new AbortController();
|
||||
controllerRef.current = controller;
|
||||
|
||||
// Release the loading guard so fetchPage can proceed.
|
||||
// This is synchronous with the fetchPage call below, so the observer
|
||||
// cannot sneak in between these two lines.
|
||||
isLoadingRef.current = false;
|
||||
fetchPage(1, false, checkId, controller.signal);
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
observerRef.current?.disconnect();
|
||||
};
|
||||
});
|
||||
|
||||
function loadNextPage() {
|
||||
const signal = controllerRef.current?.signal;
|
||||
if (
|
||||
!hasMoreRef.current ||
|
||||
isLoadingRef.current ||
|
||||
!signal ||
|
||||
signal.aborted
|
||||
)
|
||||
return;
|
||||
|
||||
const nextPage = pageRef.current + 1;
|
||||
pageRef.current = nextPage;
|
||||
fetchPage(nextPage, true, currentCheckIdRef.current, signal);
|
||||
}
|
||||
|
||||
// IntersectionObserver callback
|
||||
function handleIntersection(entries: IntersectionObserverEntry[]) {
|
||||
const [entry] = entries;
|
||||
if (entry.isIntersecting) {
|
||||
loadNextPage();
|
||||
}
|
||||
}
|
||||
|
||||
// Set up observer when sentinel node changes
|
||||
function sentinelRef(node: HTMLDivElement | null) {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
observerRef.current = null;
|
||||
}
|
||||
|
||||
if (node) {
|
||||
observerRef.current = new IntersectionObserver(handleIntersection, {
|
||||
root: scrollContainerRef?.current ?? null,
|
||||
rootMargin: "200px",
|
||||
});
|
||||
observerRef.current.observe(node);
|
||||
}
|
||||
}
|
||||
|
||||
/** Imperatively reset and re-fetch page 1 without changing deps. */
|
||||
function refresh() {
|
||||
controllerRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
controllerRef.current = controller;
|
||||
|
||||
pageRef.current = 1;
|
||||
hasMoreRef.current = true;
|
||||
isLoadingRef.current = false;
|
||||
|
||||
fetchPage(1, false, currentCheckIdRef.current, controller.signal);
|
||||
}
|
||||
|
||||
return { sentinelRef, refresh, loadMore: loadNextPage };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { EffectCallback, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Runs an effect exactly once on component mount.
|
||||
* Project-approved wrapper — use this instead of useEffect(..., []).
|
||||
*/
|
||||
export function useMountEffect(effect: EffectCallback) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(effect, []);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
/**
|
||||
* Formats a duration in seconds to a human-readable string like "2h 5m 30s".
|
||||
*/
|
||||
export function formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
|
||||
const parts = [];
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0) parts.push(`${minutes}m`);
|
||||
if (remainingSeconds > 0 || parts.length === 0)
|
||||
parts.push(`${remainingSeconds}s`);
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date string to a relative time like "3 days ago".
|
||||
* Returns the fallback string if the date is null.
|
||||
*/
|
||||
export function formatRelativeTime(
|
||||
date: string | null,
|
||||
fallback = "Never",
|
||||
): string {
|
||||
if (!date) return fallback;
|
||||
return formatDistanceToNow(new Date(date), { addSuffix: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a human-readable "failing for" duration from first_seen_at to now.
|
||||
* Returns null if the date is invalid or not provided.
|
||||
*/
|
||||
export 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" : ""}`;
|
||||
}
|
||||
+1
-1
@@ -83,7 +83,7 @@ export const getMenuList = ({ pathname }: MenuListOptions): GroupProps[] => {
|
||||
groupLabel: "",
|
||||
menus: [
|
||||
{
|
||||
href: "/findings?filter[muted]=false",
|
||||
href: "/findings?filter[muted]=false&filter[status__in]=FAIL",
|
||||
label: "Findings",
|
||||
icon: Tag,
|
||||
},
|
||||
|
||||
@@ -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 "🌐";
|
||||
}
|
||||
@@ -81,6 +81,9 @@
|
||||
/* Progress Bar */
|
||||
--shadow-progress-glow:
|
||||
0 0 10px var(--bg-button-primary), 0 0 5px var(--bg-button-primary);
|
||||
|
||||
/* Lighthouse AI */
|
||||
--gradient-lighthouse: linear-gradient(96deg, #2EE59B 3.55%, #62DFF0 98.85%);
|
||||
}
|
||||
|
||||
/* ===== DARK THEME ===== */
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
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;
|
||||
resourceGroup: 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