mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-04-15 00:57:55 +00:00
Compare commits
3 Commits
PROWLER-13
...
fix/ci-rem
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed6af6e003 | ||
|
|
507b0882d5 | ||
|
|
89d72cf8fd |
13
.github/actions/setup-python-poetry/action.yml
vendored
13
.github/actions/setup-python-poetry/action.yml
vendored
@@ -64,19 +64,6 @@ runs:
|
||||
echo "Updated resolved_reference:"
|
||||
grep -A2 -B2 "resolved_reference" poetry.lock
|
||||
|
||||
- name: Update SDK resolved_reference to latest commit (prowler repo on push)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master' && github.repository == 'prowler-cloud/prowler'
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: |
|
||||
LATEST_COMMIT=$(curl -s "https://api.github.com/repos/prowler-cloud/prowler/commits/master" | jq -r '.sha')
|
||||
echo "Latest commit hash: $LATEST_COMMIT"
|
||||
sed -i '/url = "https:\/\/github\.com\/prowler-cloud\/prowler\.git"/,/resolved_reference = / {
|
||||
s/resolved_reference = "[a-f0-9]\{40\}"/resolved_reference = "'"$LATEST_COMMIT"'"/
|
||||
}' poetry.lock
|
||||
echo "Updated resolved_reference:"
|
||||
grep -A2 -B2 "resolved_reference" poetry.lock
|
||||
|
||||
- name: Update poetry.lock (prowler repo only)
|
||||
if: github.repository == 'prowler-cloud/prowler' && inputs.update-lock == 'true'
|
||||
shell: bash
|
||||
|
||||
@@ -2,10 +2,15 @@
|
||||
|
||||
All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
## [1.23.1] (Prowler UNRELEASED)
|
||||
## [1.24.0] (Prowler UNRELEASED)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Resources side drawer with redesigned detail panel [(#10673)](https://github.com/prowler-cloud/prowler/pull/10673)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Findings group resource filters now strip unsupported scan parameters, display scan name instead of provider alias in filter badges, migrate mute modal from HeroUI to shadcn, and add searchable accounts/provider type selectors [(#10662)](https://github.com/prowler-cloud/prowler/pull/10662)
|
||||
- Compliance detail page header now reflects the actual provider, alias and UID of the selected scan instead of always defaulting to AWS [(#10674)](https://github.com/prowler-cloud/prowler/pull/10674)
|
||||
|
||||
---
|
||||
|
||||
@@ -44,13 +44,87 @@ vi.mock("next/navigation", () => ({
|
||||
|
||||
import {
|
||||
getFindingGroupResources,
|
||||
getFindingGroups,
|
||||
getLatestFindingGroupResources,
|
||||
getLatestFindingGroups,
|
||||
} from "./finding-groups";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getFindingGroups — default sort for muted and non-muted rows", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
handleApiResponseMock.mockResolvedValue({ data: [] });
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
});
|
||||
|
||||
it("should prefer non-muted fail counters when include muted is not active", async () => {
|
||||
// When
|
||||
await getFindingGroups();
|
||||
|
||||
// Then
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.get("sort")).toBe(
|
||||
"-status,-new_fail_count,-changed_fail_count,-severity,-fail_count,-last_seen_at",
|
||||
);
|
||||
});
|
||||
|
||||
it("should include muted counters when filter[muted]=include is active", async () => {
|
||||
// When
|
||||
await getFindingGroups({
|
||||
filters: { "filter[muted]": "include" },
|
||||
});
|
||||
|
||||
// Then
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.get("sort")).toBe(
|
||||
"-status,-new_fail_count,-changed_fail_count,-severity,-new_fail_muted_count,-changed_fail_muted_count,-fail_count,-fail_muted_count,-last_seen_at",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLatestFindingGroups — default sort for muted and non-muted rows", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
handleApiResponseMock.mockResolvedValue({ data: [] });
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
});
|
||||
|
||||
it("should prefer non-muted fail counters when include muted is not active", async () => {
|
||||
// When
|
||||
await getLatestFindingGroups();
|
||||
|
||||
// Then
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.get("sort")).toBe(
|
||||
"-status,-new_fail_count,-changed_fail_count,-severity,-fail_count,-last_seen_at",
|
||||
);
|
||||
});
|
||||
|
||||
it("should include muted counters when filter[muted]=include is active", async () => {
|
||||
// When
|
||||
await getLatestFindingGroups({
|
||||
filters: { "filter[muted]": "include" },
|
||||
});
|
||||
|
||||
// Then
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.get("sort")).toBe(
|
||||
"-status,-new_fail_count,-changed_fail_count,-severity,-new_fail_muted_count,-changed_fail_muted_count,-fail_count,-fail_muted_count,-last_seen_at",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFindingGroupResources — SSRF path traversal protection", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -374,6 +448,28 @@ describe("getFindingGroupResources — caller filters are preserved", () => {
|
||||
expect(url.searchParams.get("filter[name__icontains]")).toBe("bucket-prod");
|
||||
expect(url.searchParams.get("filter[severity__in]")).toBe("high");
|
||||
});
|
||||
|
||||
it("should strip scan filters that the group resources endpoint does not accept", async () => {
|
||||
// Given
|
||||
const checkId = "s3_bucket_public_access";
|
||||
const filters = {
|
||||
"filter[scan__in]": "scan-1",
|
||||
"filter[scan_id]": "scan-1",
|
||||
"filter[scan_id__in]": "scan-1,scan-2",
|
||||
"filter[region__in]": "eu-west-1",
|
||||
};
|
||||
|
||||
// When
|
||||
await getFindingGroupResources({ checkId, filters });
|
||||
|
||||
// Then
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.get("filter[scan__in]")).toBeNull();
|
||||
expect(url.searchParams.get("filter[scan_id]")).toBeNull();
|
||||
expect(url.searchParams.get("filter[scan_id__in]")).toBeNull();
|
||||
expect(url.searchParams.get("filter[region__in]")).toBe("eu-west-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLatestFindingGroupResources — caller filters are preserved", () => {
|
||||
@@ -444,4 +540,24 @@ describe("getLatestFindingGroupResources — caller filters are preserved", () =
|
||||
);
|
||||
expect(url.searchParams.get("filter[status__in]")).toBe("PASS,FAIL");
|
||||
});
|
||||
|
||||
it("should strip scan filters before calling latest group resources", async () => {
|
||||
// Given
|
||||
const checkId = "iam_user_mfa_enabled";
|
||||
const filters = {
|
||||
"filter[scan__in]": "scan-1",
|
||||
"filter[scan_id]": "scan-1",
|
||||
"filter[region__in]": "us-east-1",
|
||||
};
|
||||
|
||||
// When
|
||||
await getLatestFindingGroupResources({ checkId, filters });
|
||||
|
||||
// Then
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.get("filter[scan__in]")).toBeNull();
|
||||
expect(url.searchParams.get("filter[scan_id]")).toBeNull();
|
||||
expect(url.searchParams.get("filter[region__in]")).toBe("us-east-1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { appendSanitizedProviderFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { FilterParam } from "@/types/filters";
|
||||
|
||||
/**
|
||||
* Maps filter[search] to filter[check_title__icontains] for finding-groups.
|
||||
@@ -46,7 +47,12 @@ function splitCsvFilterValues(value: string | string[] | undefined): string[] {
|
||||
* finding-group resources sub-endpoint. These must be stripped before
|
||||
* calling the resources API to avoid empty results.
|
||||
*/
|
||||
const FINDING_GROUP_ONLY_FILTERS = ["filter[service__in]"] as const;
|
||||
const FINDING_GROUP_RESOURCE_UNSUPPORTED_FILTERS: FilterParam[] = [
|
||||
"filter[service__in]",
|
||||
"filter[scan__in]",
|
||||
"filter[scan_id]",
|
||||
"filter[scan_id__in]",
|
||||
];
|
||||
|
||||
function normalizeFindingGroupResourceFilters(
|
||||
filters: Record<string, string | string[] | undefined>,
|
||||
@@ -54,8 +60,8 @@ function normalizeFindingGroupResourceFilters(
|
||||
const normalized = Object.fromEntries(
|
||||
Object.entries(filters).filter(
|
||||
([key]) =>
|
||||
!FINDING_GROUP_ONLY_FILTERS.includes(
|
||||
key as (typeof FINDING_GROUP_ONLY_FILTERS)[number],
|
||||
!FINDING_GROUP_RESOURCE_UNSUPPORTED_FILTERS.includes(
|
||||
key as FilterParam,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -77,7 +83,10 @@ function normalizeFindingGroupResourceFilters(
|
||||
}
|
||||
|
||||
const DEFAULT_FINDING_GROUPS_SORT =
|
||||
"-status,-severity,-delta,-fail_count,-last_seen_at";
|
||||
"-status,-new_fail_count,-changed_fail_count,-severity,-fail_count,-last_seen_at";
|
||||
|
||||
const DEFAULT_FINDING_GROUPS_SORT_WITH_MUTED =
|
||||
"-status,-new_fail_count,-changed_fail_count,-severity,-new_fail_muted_count,-changed_fail_muted_count,-fail_count,-fail_muted_count,-last_seen_at";
|
||||
|
||||
const DEFAULT_FINDING_GROUP_RESOURCES_SORT =
|
||||
"-status,-delta,-severity,-last_seen_at";
|
||||
@@ -89,16 +98,32 @@ interface FetchFindingGroupsParams {
|
||||
filters?: Record<string, string | string[] | undefined>;
|
||||
}
|
||||
|
||||
function includesMutedFindings(
|
||||
filters: Record<string, string | string[] | undefined>,
|
||||
): boolean {
|
||||
const mutedFilter = filters["filter[muted]"];
|
||||
|
||||
if (Array.isArray(mutedFilter)) {
|
||||
return mutedFilter.includes("include");
|
||||
}
|
||||
|
||||
return mutedFilter === "include";
|
||||
}
|
||||
|
||||
function getDefaultFindingGroupsSort(
|
||||
filters: Record<string, string | string[] | undefined>,
|
||||
): string {
|
||||
return includesMutedFindings(filters)
|
||||
? DEFAULT_FINDING_GROUPS_SORT_WITH_MUTED
|
||||
: DEFAULT_FINDING_GROUPS_SORT;
|
||||
}
|
||||
|
||||
async function fetchFindingGroupsEndpoint(
|
||||
endpoint: string,
|
||||
{
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
sort = DEFAULT_FINDING_GROUPS_SORT,
|
||||
filters = {},
|
||||
}: FetchFindingGroupsParams,
|
||||
{ page = 1, pageSize = 10, sort, filters = {} }: FetchFindingGroupsParams,
|
||||
) {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const resolvedSort = sort ?? getDefaultFindingGroupsSort(filters);
|
||||
|
||||
if (isNaN(Number(page)) || page < 1) redirect("/findings");
|
||||
|
||||
@@ -106,7 +131,7 @@ async function fetchFindingGroupsEndpoint(
|
||||
|
||||
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);
|
||||
if (resolvedSort) url.searchParams.append("sort", resolvedSort);
|
||||
|
||||
appendSanitizedProviderFilters(url, mapSearchFilter(filters));
|
||||
|
||||
|
||||
@@ -144,22 +144,36 @@ export const createMuteRule = async (
|
||||
},
|
||||
},
|
||||
};
|
||||
const requestBody = JSON.stringify(bodyData);
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(bodyData),
|
||||
body: requestBody,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `Failed to create mute rule: ${response.statusText}`;
|
||||
const responseContentType = response.headers.get("content-type");
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMessage =
|
||||
errorData?.errors?.[0]?.detail || errorData?.message || errorMessage;
|
||||
if (responseContentType?.includes("application/json")) {
|
||||
const errorData = await response.json();
|
||||
errorMessage =
|
||||
(
|
||||
errorData as {
|
||||
errors?: Array<{ detail?: string }>;
|
||||
message?: string;
|
||||
}
|
||||
)?.errors?.[0]?.detail ||
|
||||
(errorData as { message?: string })?.message ||
|
||||
errorMessage;
|
||||
} else {
|
||||
await response.text();
|
||||
}
|
||||
} catch {
|
||||
// JSON parsing failed, use default error message
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { AccountsSelector } from "./accounts-selector";
|
||||
|
||||
const multiSelectContentSpy = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-url-filters", () => ({
|
||||
useUrlFilters: () => ({
|
||||
navigateWithParams: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/icons/providers-badge", () => ({
|
||||
AWSProviderBadge: () => <span>AWS</span>,
|
||||
AzureProviderBadge: () => <span>Azure</span>,
|
||||
GCPProviderBadge: () => <span>GCP</span>,
|
||||
CloudflareProviderBadge: () => <span>Cloudflare</span>,
|
||||
GitHubProviderBadge: () => <span>GitHub</span>,
|
||||
GoogleWorkspaceProviderBadge: () => <span>Google Workspace</span>,
|
||||
IacProviderBadge: () => <span>IaC</span>,
|
||||
ImageProviderBadge: () => <span>Image</span>,
|
||||
KS8ProviderBadge: () => <span>Kubernetes</span>,
|
||||
M365ProviderBadge: () => <span>M365</span>,
|
||||
MongoDBAtlasProviderBadge: () => <span>MongoDB Atlas</span>,
|
||||
OpenStackProviderBadge: () => <span>OpenStack</span>,
|
||||
OracleCloudProviderBadge: () => <span>Oracle Cloud</span>,
|
||||
AlibabaCloudProviderBadge: () => <span>Alibaba Cloud</span>,
|
||||
VercelProviderBadge: () => <span>Vercel</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/select/multiselect", () => ({
|
||||
MultiSelect: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
|
||||
<span>{placeholder}</span>
|
||||
),
|
||||
MultiSelectContent: ({
|
||||
children,
|
||||
search,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
search?: unknown;
|
||||
}) => {
|
||||
multiSelectContentSpy(search);
|
||||
return <div>{children}</div>;
|
||||
},
|
||||
MultiSelectItem: ({
|
||||
children,
|
||||
value,
|
||||
keywords,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value: string;
|
||||
keywords?: string[];
|
||||
}) => (
|
||||
<div data-value={value} data-keywords={keywords?.join("|")}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const providers = [
|
||||
{
|
||||
id: "provider-1",
|
||||
type: "providers" as const,
|
||||
attributes: {
|
||||
provider: "aws" as const,
|
||||
uid: "123456789012",
|
||||
alias: "Production AWS",
|
||||
status: "completed" as const,
|
||||
resources: 0,
|
||||
connection: {
|
||||
connected: true,
|
||||
last_checked_at: "2026-04-13T00:00:00Z",
|
||||
},
|
||||
scanner_args: {
|
||||
only_logs: false,
|
||||
excluded_checks: [],
|
||||
aws_retries_max_attempts: 3,
|
||||
},
|
||||
inserted_at: "2026-04-13T00:00:00Z",
|
||||
updated_at: "2026-04-13T00:00:00Z",
|
||||
created_by: {
|
||||
object: "user",
|
||||
id: "user-1",
|
||||
},
|
||||
},
|
||||
relationships: {
|
||||
secret: {
|
||||
data: null,
|
||||
},
|
||||
provider_groups: {
|
||||
meta: {
|
||||
count: 0,
|
||||
},
|
||||
data: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("AccountsSelector", () => {
|
||||
it("passes searchable dropdown defaults to MultiSelectContent", () => {
|
||||
render(<AccountsSelector providers={providers} />);
|
||||
|
||||
expect(multiSelectContentSpy).toHaveBeenCalledWith({
|
||||
placeholder: "Search accounts...",
|
||||
emptyMessage: "No accounts found.",
|
||||
});
|
||||
expect(screen.getByText("Production AWS")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("allows disabling search explicitly", () => {
|
||||
render(<AccountsSelector providers={providers} search={false} />);
|
||||
|
||||
expect(multiSelectContentSpy).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it("passes visible account labels as search keywords instead of only the internal id", () => {
|
||||
render(<AccountsSelector providers={providers} />);
|
||||
|
||||
expect(
|
||||
screen.getByText("Production AWS").closest("[data-value]"),
|
||||
).toHaveAttribute(
|
||||
"data-keywords",
|
||||
expect.stringContaining("Production AWS"),
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Production AWS").closest("[data-value]"),
|
||||
).toHaveAttribute("data-keywords", expect.stringContaining("123456789012"));
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
MultiSelect,
|
||||
MultiSelectContent,
|
||||
MultiSelectItem,
|
||||
type MultiSelectSearchProp,
|
||||
MultiSelectTrigger,
|
||||
MultiSelectValue,
|
||||
} from "@/components/shadcn/select/multiselect";
|
||||
@@ -55,6 +56,7 @@ const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
|
||||
/** Common props shared by both batch and instant modes. */
|
||||
interface AccountsSelectorBaseProps {
|
||||
providers: ProviderProps[];
|
||||
search?: MultiSelectSearchProp;
|
||||
/**
|
||||
* Currently selected provider types (from the pending ProviderTypeSelector state).
|
||||
* Used only for contextual description/empty-state messaging — does NOT narrow
|
||||
@@ -95,6 +97,10 @@ export function AccountsSelector({
|
||||
onBatchChange,
|
||||
selectedValues,
|
||||
selectedProviderTypes,
|
||||
search = {
|
||||
placeholder: "Search accounts...",
|
||||
emptyMessage: "No accounts found.",
|
||||
},
|
||||
}: AccountsSelectorProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const { navigateWithParams } = useUrlFilters();
|
||||
@@ -159,7 +165,7 @@ export function AccountsSelector({
|
||||
>
|
||||
{selectedLabel() || <MultiSelectValue placeholder="All accounts" />}
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent search={false}>
|
||||
<MultiSelectContent search={search}>
|
||||
{visibleProviders.length > 0 ? (
|
||||
<>
|
||||
<div
|
||||
@@ -183,11 +189,19 @@ export function AccountsSelector({
|
||||
const displayName = p.attributes.alias || p.attributes.uid;
|
||||
const providerType = p.attributes.provider as ProviderType;
|
||||
const icon = PROVIDER_ICON[providerType];
|
||||
const searchKeywords = [
|
||||
displayName,
|
||||
p.attributes.alias,
|
||||
p.attributes.uid,
|
||||
providerType,
|
||||
getProviderDisplayName(providerType),
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<MultiSelectItem
|
||||
key={id}
|
||||
value={id}
|
||||
badgeLabel={displayName}
|
||||
keywords={searchKeywords}
|
||||
aria-label={`${displayName} account (${providerType.toUpperCase()})`}
|
||||
>
|
||||
<span aria-hidden="true">{icon}</span>
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ProviderTypeSelector } from "./provider-type-selector";
|
||||
|
||||
const multiSelectContentSpy = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-url-filters", () => ({
|
||||
useUrlFilters: () => ({
|
||||
navigateWithParams: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/icons/providers-badge", () => ({
|
||||
AWSProviderBadge: () => <span>AWS</span>,
|
||||
AzureProviderBadge: () => <span>Azure</span>,
|
||||
GCPProviderBadge: () => <span>GCP</span>,
|
||||
KS8ProviderBadge: () => <span>Kubernetes</span>,
|
||||
M365ProviderBadge: () => <span>M365</span>,
|
||||
GitHubProviderBadge: () => <span>GitHub</span>,
|
||||
GoogleWorkspaceProviderBadge: () => <span>Google Workspace</span>,
|
||||
IacProviderBadge: () => <span>IaC</span>,
|
||||
ImageProviderBadge: () => <span>Image</span>,
|
||||
OracleCloudProviderBadge: () => <span>Oracle Cloud</span>,
|
||||
MongoDBAtlasProviderBadge: () => <span>MongoDB Atlas</span>,
|
||||
AlibabaCloudProviderBadge: () => <span>Alibaba Cloud</span>,
|
||||
CloudflareProviderBadge: () => <span>Cloudflare</span>,
|
||||
OpenStackProviderBadge: () => <span>OpenStack</span>,
|
||||
VercelProviderBadge: () => <span>Vercel</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/select/multiselect", () => ({
|
||||
MultiSelect: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
|
||||
<span>{placeholder}</span>
|
||||
),
|
||||
MultiSelectContent: ({
|
||||
children,
|
||||
search,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
search?: unknown;
|
||||
}) => {
|
||||
multiSelectContentSpy(search);
|
||||
return <div>{children}</div>;
|
||||
},
|
||||
MultiSelectItem: ({
|
||||
children,
|
||||
value,
|
||||
keywords,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value: string;
|
||||
keywords?: string[];
|
||||
}) => (
|
||||
<div data-value={value} data-keywords={keywords?.join("|")}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const providers = [
|
||||
{
|
||||
id: "provider-1",
|
||||
type: "providers" as const,
|
||||
attributes: {
|
||||
provider: "aws" as const,
|
||||
uid: "123456789012",
|
||||
alias: "Production AWS",
|
||||
status: "completed" as const,
|
||||
resources: 0,
|
||||
connection: {
|
||||
connected: true,
|
||||
last_checked_at: "2026-04-13T00:00:00Z",
|
||||
},
|
||||
scanner_args: {
|
||||
only_logs: false,
|
||||
excluded_checks: [],
|
||||
aws_retries_max_attempts: 3,
|
||||
},
|
||||
inserted_at: "2026-04-13T00:00:00Z",
|
||||
updated_at: "2026-04-13T00:00:00Z",
|
||||
created_by: {
|
||||
object: "user",
|
||||
id: "user-1",
|
||||
},
|
||||
},
|
||||
relationships: {
|
||||
secret: {
|
||||
data: null,
|
||||
},
|
||||
provider_groups: {
|
||||
meta: {
|
||||
count: 0,
|
||||
},
|
||||
data: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("ProviderTypeSelector", () => {
|
||||
it("passes searchable dropdown defaults to MultiSelectContent", () => {
|
||||
render(<ProviderTypeSelector providers={providers} />);
|
||||
|
||||
expect(multiSelectContentSpy).toHaveBeenCalledWith({
|
||||
placeholder: "Search providers...",
|
||||
emptyMessage: "No providers found.",
|
||||
});
|
||||
expect(screen.getByText("Amazon Web Services")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("allows disabling search explicitly", () => {
|
||||
render(<ProviderTypeSelector providers={providers} search={false} />);
|
||||
|
||||
expect(multiSelectContentSpy).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it("passes provider label as search keywords", () => {
|
||||
render(<ProviderTypeSelector providers={providers} />);
|
||||
|
||||
expect(
|
||||
screen.getByText("Amazon Web Services").closest("[data-value]"),
|
||||
).toHaveAttribute(
|
||||
"data-keywords",
|
||||
expect.stringContaining("Amazon Web Services"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
MultiSelect,
|
||||
MultiSelectContent,
|
||||
MultiSelectItem,
|
||||
type MultiSelectSearchProp,
|
||||
MultiSelectTrigger,
|
||||
MultiSelectValue,
|
||||
} from "@/components/shadcn/select/multiselect";
|
||||
@@ -164,6 +165,7 @@ const PROVIDER_DATA: Record<
|
||||
/** Common props shared by both batch and instant modes. */
|
||||
interface ProviderTypeSelectorBaseProps {
|
||||
providers: ProviderProps[];
|
||||
search?: MultiSelectSearchProp;
|
||||
}
|
||||
|
||||
/** Batch mode: caller controls both pending state and notification callback (all-or-nothing). */
|
||||
@@ -198,6 +200,10 @@ export const ProviderTypeSelector = ({
|
||||
providers,
|
||||
onBatchChange,
|
||||
selectedValues,
|
||||
search = {
|
||||
placeholder: "Search providers...",
|
||||
emptyMessage: "No providers found.",
|
||||
},
|
||||
}: ProviderTypeSelectorProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const { navigateWithParams } = useUrlFilters();
|
||||
@@ -284,7 +290,7 @@ export const ProviderTypeSelector = ({
|
||||
>
|
||||
{selectedLabel() || <MultiSelectValue placeholder="All providers" />}
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent search={false}>
|
||||
<MultiSelectContent search={search}>
|
||||
{availableTypes.length > 0 ? (
|
||||
<>
|
||||
<div
|
||||
@@ -308,6 +314,7 @@ export const ProviderTypeSelector = ({
|
||||
key={providerType}
|
||||
value={providerType}
|
||||
badgeLabel={PROVIDER_DATA[providerType].label}
|
||||
keywords={[providerType, PROVIDER_DATA[providerType].label]}
|
||||
aria-label={`${PROVIDER_DATA[providerType].label} provider`}
|
||||
>
|
||||
<span aria-hidden="true">{renderIcon(providerType)}</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { InfoField } from "@/components/shadcn/info-field/info-field";
|
||||
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
|
||||
import { InfoField } from "@/components/ui/entities";
|
||||
import { DateWithTime } from "@/components/ui/entities/date-with-time";
|
||||
import type { GraphNode, GraphNodePropertyValue } from "@/types/attack-paths";
|
||||
|
||||
|
||||
@@ -1,399 +1 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
HighlightStyle,
|
||||
StreamLanguage,
|
||||
syntaxHighlighting,
|
||||
} from "@codemirror/language";
|
||||
import { tags } from "@lezer/highlight";
|
||||
import CodeMirror, {
|
||||
EditorView,
|
||||
highlightActiveLineGutter,
|
||||
lineNumbers,
|
||||
placeholder as codeEditorPlaceholder,
|
||||
} from "@uiw/react-codemirror";
|
||||
import { useTheme } from "next-themes";
|
||||
import { type HTMLAttributes } from "react";
|
||||
|
||||
import { Badge } from "@/components/shadcn";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const OPEN_CYPHER_KEYWORDS = new Set([
|
||||
"all",
|
||||
"and",
|
||||
"as",
|
||||
"asc",
|
||||
"ascending",
|
||||
"by",
|
||||
"call",
|
||||
"case",
|
||||
"contains",
|
||||
"create",
|
||||
"delete",
|
||||
"desc",
|
||||
"descending",
|
||||
"detach",
|
||||
"distinct",
|
||||
"else",
|
||||
"end",
|
||||
"exists",
|
||||
"false",
|
||||
"in",
|
||||
"is",
|
||||
"limit",
|
||||
"match",
|
||||
"merge",
|
||||
"not",
|
||||
"null",
|
||||
"optional",
|
||||
"or",
|
||||
"order",
|
||||
"remove",
|
||||
"return",
|
||||
"set",
|
||||
"skip",
|
||||
"then",
|
||||
"true",
|
||||
"unwind",
|
||||
"where",
|
||||
"with",
|
||||
"xor",
|
||||
"yield",
|
||||
]);
|
||||
|
||||
const OPEN_CYPHER_FUNCTIONS = new Set([
|
||||
"collect",
|
||||
"coalesce",
|
||||
"count",
|
||||
"exists",
|
||||
"head",
|
||||
"id",
|
||||
"keys",
|
||||
"labels",
|
||||
"last",
|
||||
"length",
|
||||
"nodes",
|
||||
"properties",
|
||||
"range",
|
||||
"reduce",
|
||||
"relationships",
|
||||
"size",
|
||||
"startnode",
|
||||
"sum",
|
||||
"tail",
|
||||
"timestamp",
|
||||
"tolower",
|
||||
"toupper",
|
||||
"trim",
|
||||
"type",
|
||||
]);
|
||||
|
||||
interface OpenCypherParserState {
|
||||
inBlockComment: boolean;
|
||||
inString: "'" | '"' | null;
|
||||
}
|
||||
|
||||
const openCypherLanguage = StreamLanguage.define<OpenCypherParserState>({
|
||||
startState() {
|
||||
return {
|
||||
inBlockComment: false,
|
||||
inString: null,
|
||||
};
|
||||
},
|
||||
token(stream, state) {
|
||||
if (state.inBlockComment) {
|
||||
while (!stream.eol()) {
|
||||
if (stream.match("*/")) {
|
||||
state.inBlockComment = false;
|
||||
break;
|
||||
}
|
||||
stream.next();
|
||||
}
|
||||
|
||||
return "comment";
|
||||
}
|
||||
|
||||
if (state.inString) {
|
||||
let escaped = false;
|
||||
|
||||
while (!stream.eol()) {
|
||||
const next = stream.next();
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (next === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (next === state.inString) {
|
||||
state.inString = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// OpenCypher only supports single-line strings — reset at EOL so an
|
||||
// unclosed quote does not bleed into subsequent lines.
|
||||
if (stream.eol()) {
|
||||
state.inString = null;
|
||||
}
|
||||
|
||||
return "string";
|
||||
}
|
||||
|
||||
if (stream.eatSpace()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (stream.match("//")) {
|
||||
stream.skipToEnd();
|
||||
return "comment";
|
||||
}
|
||||
|
||||
if (stream.match("/*")) {
|
||||
state.inBlockComment = true;
|
||||
return "comment";
|
||||
}
|
||||
|
||||
const quote = stream.peek();
|
||||
if (quote === "'" || quote === '"') {
|
||||
state.inString = quote;
|
||||
stream.next();
|
||||
return "string";
|
||||
}
|
||||
|
||||
if (stream.match(/\$[A-Za-z_][\w]*/)) {
|
||||
return "variableName";
|
||||
}
|
||||
|
||||
if (stream.match(/:[A-Za-z_][\w]*/)) {
|
||||
return "typeName";
|
||||
}
|
||||
|
||||
if (stream.match(/[()[\]{},.;]/)) {
|
||||
return "punctuation";
|
||||
}
|
||||
|
||||
if (stream.match(/[<>!=~|&+\-/*%^]+/)) {
|
||||
return "operator";
|
||||
}
|
||||
|
||||
if (stream.match(/\d+(?:\.\d+)?/)) {
|
||||
return "number";
|
||||
}
|
||||
|
||||
if (stream.match(/[A-Za-z_][\w]*/)) {
|
||||
const currentValue = stream.current();
|
||||
const normalizedValue = currentValue.toLowerCase();
|
||||
|
||||
if (OPEN_CYPHER_KEYWORDS.has(normalizedValue)) {
|
||||
return "keyword";
|
||||
}
|
||||
|
||||
if (
|
||||
OPEN_CYPHER_FUNCTIONS.has(normalizedValue) &&
|
||||
stream.match(/\s*(?=\()/, false)
|
||||
) {
|
||||
return "function";
|
||||
}
|
||||
|
||||
return "variableName";
|
||||
}
|
||||
|
||||
stream.next();
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
const lightHighlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: "#0550ae", fontWeight: "600" },
|
||||
{ tag: tags.string, color: "#0a3069" },
|
||||
{ tag: tags.number, color: "#8250df" },
|
||||
{ tag: [tags.typeName, tags.className], color: "#953800" },
|
||||
{ tag: [tags.variableName, tags.propertyName], color: "#24292f" },
|
||||
{ tag: tags.function(tags.variableName), color: "#8250df" },
|
||||
{ tag: tags.operator, color: "#57606a" },
|
||||
{ tag: tags.comment, color: "#6e7781", fontStyle: "italic" },
|
||||
{ tag: tags.punctuation, color: "#57606a" },
|
||||
]);
|
||||
|
||||
const darkHighlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: "#79c0ff", fontWeight: "600" },
|
||||
{ tag: tags.string, color: "#a5d6ff" },
|
||||
{ tag: tags.number, color: "#d2a8ff" },
|
||||
{ tag: [tags.typeName, tags.className], color: "#ffa657" },
|
||||
{ tag: [tags.variableName, tags.propertyName], color: "#e6edf3" },
|
||||
{ tag: tags.function(tags.variableName), color: "#d2a8ff" },
|
||||
{ tag: tags.operator, color: "#8b949e" },
|
||||
{ tag: tags.comment, color: "#8b949e", fontStyle: "italic" },
|
||||
{ tag: tags.punctuation, color: "#8b949e" },
|
||||
]);
|
||||
|
||||
const MONO_FONT =
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace';
|
||||
|
||||
const baseThemeRules: Record<string, Record<string, string>> = {
|
||||
"&": {
|
||||
backgroundColor: "transparent",
|
||||
color: "var(--text-neutral-primary)",
|
||||
fontFamily: MONO_FONT,
|
||||
fontSize: "12px",
|
||||
},
|
||||
"&.cm-focused": {
|
||||
outline: "none",
|
||||
},
|
||||
".cm-scroller": {
|
||||
minHeight: "320px",
|
||||
overflow: "auto",
|
||||
fontFamily: MONO_FONT,
|
||||
lineHeight: "1.5rem",
|
||||
},
|
||||
".cm-content": {
|
||||
padding: "16px",
|
||||
caretColor: "var(--text-neutral-primary)",
|
||||
},
|
||||
".cm-line": {
|
||||
padding: "0 0 0 8px",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "var(--bg-neutral-secondary)",
|
||||
color: "var(--text-neutral-tertiary)",
|
||||
borderRight: "1px solid var(--border-neutral-secondary)",
|
||||
minWidth: "44px",
|
||||
},
|
||||
".cm-lineNumbers .cm-gutterElement": {
|
||||
padding: "0 10px 0 12px",
|
||||
},
|
||||
".cm-activeLineGutter": {
|
||||
backgroundColor: "var(--bg-neutral-secondary)",
|
||||
color: "var(--text-neutral-secondary)",
|
||||
},
|
||||
".cm-activeLine": {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
".cm-cursor, .cm-dropCursor": {
|
||||
borderLeftColor: "var(--text-neutral-primary)",
|
||||
},
|
||||
".cm-placeholder": {
|
||||
color: "var(--text-neutral-tertiary)",
|
||||
},
|
||||
};
|
||||
|
||||
const LIGHT_SELECTION_BG = "rgba(9, 105, 218, 0.18)";
|
||||
const DARK_SELECTION_BG = "rgba(121, 192, 255, 0.18)";
|
||||
|
||||
const lightTheme = EditorView.theme(
|
||||
{
|
||||
...baseThemeRules,
|
||||
".cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection":
|
||||
{ backgroundColor: LIGHT_SELECTION_BG },
|
||||
},
|
||||
{ dark: false },
|
||||
);
|
||||
|
||||
const darkTheme = EditorView.theme(
|
||||
{
|
||||
...baseThemeRules,
|
||||
".cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection":
|
||||
{ backgroundColor: DARK_SELECTION_BG },
|
||||
},
|
||||
{ dark: true },
|
||||
);
|
||||
|
||||
interface QueryCodeEditorProps
|
||||
extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
|
||||
ariaLabel: string;
|
||||
language?: "openCypher";
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
invalid?: boolean;
|
||||
requirementBadge?: string;
|
||||
onChange: (value: string) => void;
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
export const QueryCodeEditor = ({
|
||||
id,
|
||||
className,
|
||||
ariaLabel,
|
||||
language = "openCypher",
|
||||
value,
|
||||
placeholder,
|
||||
invalid = false,
|
||||
requirementBadge,
|
||||
onChange,
|
||||
onBlur,
|
||||
...props
|
||||
}: QueryCodeEditorProps) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const isDarkMode = resolvedTheme === "dark";
|
||||
const editorTheme = isDarkMode ? darkTheme : lightTheme;
|
||||
const editorHighlightStyle = isDarkMode
|
||||
? darkHighlightStyle
|
||||
: lightHighlightStyle;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="query-code-editor"
|
||||
data-language={language}
|
||||
className={cn(
|
||||
"border-border-neutral-secondary bg-bg-neutral-primary overflow-hidden rounded-xl border",
|
||||
invalid && "border-border-error-primary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex items-center justify-between border-b px-4 py-2">
|
||||
<span className="text-text-neutral-secondary text-xs font-medium">
|
||||
{ariaLabel}
|
||||
</span>
|
||||
{requirementBadge ? (
|
||||
<Badge
|
||||
variant="tag"
|
||||
className="text-text-neutral-secondary border-border-neutral-secondary bg-bg-neutral-primary px-2 py-0 text-[11px]"
|
||||
>
|
||||
{requirementBadge}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<CodeMirror
|
||||
value={value}
|
||||
theme={editorTheme}
|
||||
basicSetup={{
|
||||
foldGutter: false,
|
||||
highlightActiveLine: false,
|
||||
highlightActiveLineGutter: false,
|
||||
searchKeymap: false,
|
||||
}}
|
||||
editable={true}
|
||||
onChange={onChange}
|
||||
extensions={[
|
||||
lineNumbers(),
|
||||
highlightActiveLineGutter(),
|
||||
EditorView.lineWrapping,
|
||||
codeEditorPlaceholder(placeholder ?? ""),
|
||||
openCypherLanguage,
|
||||
syntaxHighlighting(editorHighlightStyle),
|
||||
EditorView.contentAttributes.of({
|
||||
id: id ?? "",
|
||||
"aria-label": ariaLabel,
|
||||
"aria-invalid": invalid ? "true" : "false",
|
||||
}),
|
||||
EditorView.editorAttributes.of({
|
||||
class: "minimal-scrollbar",
|
||||
}),
|
||||
EditorView.domEventHandlers({
|
||||
blur: () => {
|
||||
onBlur?.();
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export { QueryCodeEditor } from "@/components/shared/query-code-editor";
|
||||
|
||||
@@ -153,6 +153,8 @@ const SSRDataTable = async ({
|
||||
key={groupKey}
|
||||
data={groups}
|
||||
metadata={findingGroupsData?.meta}
|
||||
resolvedFilters={filters}
|
||||
hasHistoricalData={hasDateOrScan}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
31
ui/app/(prowler)/resources/page.test.ts
Normal file
31
ui/app/(prowler)/resources/page.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("resources page", () => {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const pagePath = path.join(currentDir, "page.tsx");
|
||||
const tablePath = path.join(
|
||||
currentDir,
|
||||
"../../../components/resources/table/resources-table-with-selection.tsx",
|
||||
);
|
||||
|
||||
const pageSource = readFileSync(pagePath, "utf8");
|
||||
const tableSource = readFileSync(tablePath, "utf8");
|
||||
|
||||
it("fetches the deep-linked resource on the server in parallel with the rest of the page data", () => {
|
||||
expect(pageSource).toContain("getResourceById(initialResourceId");
|
||||
expect(pageSource).toContain("await Promise.all");
|
||||
expect(pageSource).toContain("initialResource={processedResource}");
|
||||
});
|
||||
|
||||
it("keeps the client table free of deep-link fetch effects", () => {
|
||||
expect(tableSource).not.toContain("useEffect");
|
||||
expect(tableSource).not.toContain("useRef");
|
||||
expect(tableSource).not.toContain("getResourceById");
|
||||
expect(tableSource).not.toContain("initialResourceId");
|
||||
expect(tableSource).toContain("initialResource?: ResourceProps | null");
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
getResourceById,
|
||||
getResources,
|
||||
} from "@/actions/resources";
|
||||
import { ResourceDetailsSheet } from "@/components/resources/resource-details-sheet";
|
||||
import { ResourcesFilters } from "@/components/resources/resources-filters";
|
||||
import { SkeletonTableResources } from "@/components/resources/skeleton/skeleton-table-resources";
|
||||
import { ResourcesTableWithSelection } from "@/components/resources/table";
|
||||
@@ -36,8 +35,7 @@ export default async function Resources({
|
||||
// Check if the searchParams contain any date or scan filter
|
||||
const hasDateOrScan = hasDateOrScanFilter(resolvedSearchParams);
|
||||
|
||||
// Check if there's a specific resource ID to fetch
|
||||
const resourceId = resolvedSearchParams.resourceId?.toString();
|
||||
const initialResourceId = resolvedSearchParams.resourceId?.toString();
|
||||
|
||||
const [metadataInfoData, providersData, resourceByIdData] = await Promise.all(
|
||||
[
|
||||
@@ -47,35 +45,33 @@ export default async function Resources({
|
||||
sort: encodedSort,
|
||||
}),
|
||||
getProviders({ pageSize: 50 }),
|
||||
resourceId
|
||||
? getResourceById(resourceId, { include: ["provider"] })
|
||||
: Promise.resolve(null),
|
||||
initialResourceId
|
||||
? getResourceById(initialResourceId, { include: ["provider"] })
|
||||
: Promise.resolve(undefined),
|
||||
],
|
||||
);
|
||||
|
||||
// Process the resource data to match the expected structure
|
||||
const processedResource = resourceByIdData?.data
|
||||
? (() => {
|
||||
const resource = resourceByIdData.data;
|
||||
const providerDict = createDict("providers", resourceByIdData);
|
||||
|
||||
const provider = {
|
||||
data: providerDict[resource.relationships?.provider?.data?.id],
|
||||
};
|
||||
|
||||
return {
|
||||
...resource,
|
||||
relationships: {
|
||||
...resource.relationships,
|
||||
provider,
|
||||
provider: {
|
||||
data: providerDict[resource.relationships.provider.data.id],
|
||||
},
|
||||
},
|
||||
} as ResourceProps;
|
||||
} satisfies ResourceProps;
|
||||
})()
|
||||
: null;
|
||||
|
||||
// Extract unique regions, services, groups from the metadata endpoint
|
||||
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
|
||||
const uniqueServices = metadataInfoData?.data?.attributes?.services || [];
|
||||
const uniqueResourceTypes = metadataInfoData?.data?.attributes?.types || [];
|
||||
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
|
||||
|
||||
return (
|
||||
@@ -86,24 +82,27 @@ export default async function Resources({
|
||||
providers={providersData?.data || []}
|
||||
uniqueRegions={uniqueRegions}
|
||||
uniqueServices={uniqueServices}
|
||||
uniqueResourceTypes={uniqueResourceTypes}
|
||||
uniqueGroups={uniqueGroups}
|
||||
/>
|
||||
</div>
|
||||
<Suspense fallback={<SkeletonTableResources />}>
|
||||
<SSRDataTable searchParams={resolvedSearchParams} />
|
||||
<SSRDataTable
|
||||
searchParams={resolvedSearchParams}
|
||||
initialResource={processedResource}
|
||||
/>
|
||||
</Suspense>
|
||||
</FilterTransitionWrapper>
|
||||
{processedResource && (
|
||||
<ResourceDetailsSheet resource={processedResource} />
|
||||
)}
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const SSRDataTable = async ({
|
||||
searchParams,
|
||||
initialResource,
|
||||
}: {
|
||||
searchParams: SearchParamsProps;
|
||||
initialResource?: ResourceProps | null;
|
||||
}) => {
|
||||
const page = parseInt(searchParams.page?.toString() || "1", 10);
|
||||
const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10);
|
||||
@@ -175,6 +174,7 @@ const SSRDataTable = async ({
|
||||
<ResourcesTableWithSelection
|
||||
data={expandedResources || []}
|
||||
metadata={resourcesData?.meta}
|
||||
initialResource={initialResource}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -54,7 +54,9 @@ const FILTER_KEY_LABELS: Record<FilterParam, string> = {
|
||||
"filter[resource_type__in]": "Resource Type",
|
||||
"filter[category__in]": "Category",
|
||||
"filter[resource_groups__in]": "Resource Group",
|
||||
"filter[scan__in]": "Scan ID",
|
||||
"filter[scan__in]": "Scan",
|
||||
"filter[scan_id]": "Scan",
|
||||
"filter[scan_id__in]": "Scan",
|
||||
"filter[inserted_at]": "Date",
|
||||
"filter[muted]": "Muted",
|
||||
};
|
||||
|
||||
@@ -98,7 +98,7 @@ describe("getFindingsFilterDisplayValue", () => {
|
||||
it("shows the resolved scan badge label for scan filters instead of formatting the raw scan id", () => {
|
||||
expect(
|
||||
getFindingsFilterDisplayValue("filter[scan__in]", "scan-1", { scans }),
|
||||
).toBe("Scan Account");
|
||||
).toBe("Nightly scan");
|
||||
});
|
||||
|
||||
it("normalizes finding statuses for display", () => {
|
||||
@@ -133,7 +133,28 @@ describe("getFindingsFilterDisplayValue", () => {
|
||||
}),
|
||||
],
|
||||
}),
|
||||
).toBe("210987654321");
|
||||
).toBe("Weekly scan");
|
||||
});
|
||||
|
||||
it("falls back to the provider alias when the scan name is missing", () => {
|
||||
expect(
|
||||
getFindingsFilterDisplayValue("filter[scan__in]", "scan-3", {
|
||||
scans: [
|
||||
...scans,
|
||||
makeScanMap("scan-3", {
|
||||
providerInfo: {
|
||||
provider: "aws",
|
||||
alias: "Fallback Account",
|
||||
uid: "333333333333",
|
||||
},
|
||||
attributes: {
|
||||
name: "",
|
||||
completed_at: "2026-04-08T10:00:00Z",
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
).toBe("Fallback Account");
|
||||
});
|
||||
|
||||
it("keeps the raw scan value when the scan cannot be resolved", () => {
|
||||
|
||||
@@ -35,7 +35,12 @@ function getScanDisplayValue(
|
||||
return scanId;
|
||||
}
|
||||
|
||||
return scan.providerInfo.alias || scan.providerInfo.uid || scanId;
|
||||
return (
|
||||
scan.attributes.name ||
|
||||
scan.providerInfo.alias ||
|
||||
scan.providerInfo.uid ||
|
||||
scanId
|
||||
);
|
||||
}
|
||||
|
||||
export function getFindingsFilterDisplayValue(
|
||||
|
||||
165
ui/components/findings/mute-findings-modal.test.tsx
Normal file
165
ui/components/findings/mute-findings-modal.test.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { createMuteRuleMock, toastMock } = vi.hoisted(() => ({
|
||||
createMuteRuleMock: vi.fn(),
|
||||
toastMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/mute-rules", () => ({
|
||||
createMuteRule: createMuteRuleMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn", () => ({
|
||||
Button: ({
|
||||
children,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) => (
|
||||
<input {...props} />
|
||||
),
|
||||
Textarea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => (
|
||||
<textarea {...props} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/modal", () => ({
|
||||
Modal: ({
|
||||
children,
|
||||
open,
|
||||
title,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
title?: string;
|
||||
}) =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label={title}>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/skeleton/skeleton", () => ({
|
||||
Skeleton: ({ className }: { className?: string }) => (
|
||||
<div data-testid="skeleton" className={className} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/spinner/spinner", () => ({
|
||||
Spinner: ({ className }: { className?: string }) => (
|
||||
<div data-testid="spinner" className={className} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui", () => ({
|
||||
useToast: () => ({
|
||||
toast: toastMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/form", () => ({
|
||||
FormButtons: ({
|
||||
onCancel,
|
||||
submitText = "Save",
|
||||
isDisabled,
|
||||
}: {
|
||||
onCancel?: () => void;
|
||||
submitText?: string;
|
||||
isDisabled?: boolean;
|
||||
}) => (
|
||||
<div>
|
||||
<button type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={isDisabled}>
|
||||
{submitText}
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { MuteFindingsModal } from "./mute-findings-modal";
|
||||
|
||||
describe("MuteFindingsModal", () => {
|
||||
it("renders the ready state with accessible fields and descriptions", () => {
|
||||
render(
|
||||
<MuteFindingsModal
|
||||
isOpen
|
||||
onOpenChange={vi.fn()}
|
||||
findingIds={["finding-1", "finding-2"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("You are about to mute", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Rule Name")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Reason")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("A descriptive name for this mute rule"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Explain why these findings are being muted"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the preparing state and blocks submission", () => {
|
||||
render(
|
||||
<MuteFindingsModal
|
||||
isOpen
|
||||
onOpenChange={vi.fn()}
|
||||
findingIds={[]}
|
||||
isPreparing
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("Preparing findings to mute...", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Preparing..." })).toBeDisabled();
|
||||
expect(screen.queryByLabelText("Rule Name")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits the form, shows the success toast, and closes the modal", async () => {
|
||||
createMuteRuleMock.mockResolvedValue({
|
||||
success: "Mute rule created successfully! Findings are now muted.",
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
const onComplete = vi.fn();
|
||||
|
||||
render(
|
||||
<MuteFindingsModal
|
||||
isOpen
|
||||
onOpenChange={onOpenChange}
|
||||
findingIds={["finding-1", "finding-2"]}
|
||||
onComplete={onComplete}
|
||||
isBulkOperation
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByLabelText("Rule Name"), "Ignore dev buckets");
|
||||
await user.type(
|
||||
screen.getByLabelText("Reason"),
|
||||
"Expected failures in the development environment",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Mute Findings" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createMuteRuleMock).toHaveBeenCalledTimes(1);
|
||||
expect(toastMock).toHaveBeenCalledWith({
|
||||
title: "Success",
|
||||
description:
|
||||
"Mute rule created. It may take a few minutes for all findings to update.",
|
||||
});
|
||||
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Input, Textarea } from "@heroui/input";
|
||||
import { Dispatch, SetStateAction, useState, useTransition } from "react";
|
||||
|
||||
import { createMuteRule } from "@/actions/mute-rules";
|
||||
import { MuteRuleActionState } from "@/actions/mute-rules/types";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { Button, Input, Textarea } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
@@ -44,6 +43,8 @@ export function MuteFindingsModal({
|
||||
isPreparing ||
|
||||
findingIds.length === 0 ||
|
||||
Boolean(preparationError);
|
||||
const nameError = state?.errors?.name;
|
||||
const reasonError = state?.errors?.reason;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -115,10 +116,12 @@ export function MuteFindingsModal({
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-24 rounded" />
|
||||
<Skeleton className="h-11 w-full rounded-lg" />
|
||||
<Skeleton className="h-3 w-40 rounded" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-20 rounded" />
|
||||
<Skeleton className="h-24 w-full rounded-lg" />
|
||||
<Skeleton className="h-3 w-44 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -175,31 +178,78 @@ export function MuteFindingsModal({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
name="name"
|
||||
label="Rule Name"
|
||||
placeholder="e.g., Ignore dev environment S3 buckets"
|
||||
isRequired
|
||||
variant="bordered"
|
||||
isInvalid={!!state?.errors?.name}
|
||||
errorMessage={state?.errors?.name}
|
||||
isDisabled={isPending}
|
||||
description="A descriptive name for this mute rule"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
className="text-sm font-medium text-slate-900 dark:text-white"
|
||||
htmlFor="mute-rule-name"
|
||||
>
|
||||
Rule Name
|
||||
</label>
|
||||
<Input
|
||||
id="mute-rule-name"
|
||||
name="name"
|
||||
placeholder="e.g., Ignore dev environment S3 buckets"
|
||||
required
|
||||
disabled={isPending}
|
||||
aria-invalid={nameError ? "true" : "false"}
|
||||
aria-describedby={
|
||||
nameError
|
||||
? "mute-rule-name-error"
|
||||
: "mute-rule-name-description"
|
||||
}
|
||||
/>
|
||||
<p
|
||||
id="mute-rule-name-description"
|
||||
className="text-xs text-slate-500 dark:text-slate-400"
|
||||
>
|
||||
A descriptive name for this mute rule
|
||||
</p>
|
||||
{nameError ? (
|
||||
<p
|
||||
id="mute-rule-name-error"
|
||||
className="text-text-error-primary text-xs"
|
||||
>
|
||||
{nameError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
name="reason"
|
||||
label="Reason"
|
||||
placeholder="e.g., These are expected findings in the development environment"
|
||||
isRequired
|
||||
variant="bordered"
|
||||
minRows={3}
|
||||
maxRows={6}
|
||||
isInvalid={!!state?.errors?.reason}
|
||||
errorMessage={state?.errors?.reason}
|
||||
isDisabled={isPending}
|
||||
description="Explain why these findings are being muted"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
className="text-sm font-medium text-slate-900 dark:text-white"
|
||||
htmlFor="mute-rule-reason"
|
||||
>
|
||||
Reason
|
||||
</label>
|
||||
<Textarea
|
||||
id="mute-rule-reason"
|
||||
name="reason"
|
||||
placeholder="e.g., These are expected findings in the development environment"
|
||||
required
|
||||
disabled={isPending}
|
||||
rows={4}
|
||||
aria-invalid={reasonError ? "true" : "false"}
|
||||
aria-describedby={
|
||||
reasonError
|
||||
? "mute-rule-reason-error"
|
||||
: "mute-rule-reason-description"
|
||||
}
|
||||
/>
|
||||
<p
|
||||
id="mute-rule-reason-description"
|
||||
className="text-xs text-slate-500 dark:text-slate-400"
|
||||
>
|
||||
Explain why these findings are being muted
|
||||
</p>
|
||||
{reasonError ? (
|
||||
<p
|
||||
id="mute-rule-reason-error"
|
||||
className="text-text-error-primary text-xs"
|
||||
>
|
||||
{reasonError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<FormButtons
|
||||
setIsOpen={onOpenChange}
|
||||
|
||||
@@ -60,10 +60,6 @@ vi.mock("./data-table-row-actions", () => ({
|
||||
DataTableRowActions: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./impacted-providers-cell", () => ({
|
||||
ImpactedProvidersCell: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./impacted-resources-cell", () => ({
|
||||
ImpactedResourcesCell: ({
|
||||
impacted,
|
||||
@@ -218,6 +214,23 @@ function renderSelectCell(overrides?: Partial<FindingGroupRow>) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("column-finding-groups — accessibility of check title cell", () => {
|
||||
it("should not expose an impacted providers column", () => {
|
||||
// Given
|
||||
const columns = getColumnFindingGroups({
|
||||
rowSelection: {},
|
||||
selectableRowCount: 1,
|
||||
onDrillDown: vi.fn(),
|
||||
});
|
||||
|
||||
// When
|
||||
const impactedProvidersColumn = columns.find(
|
||||
(col) => (col as { id?: string }).id === "impactedProviders",
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(impactedProvidersColumn).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should render the check title as a button element (not a <p>)", () => {
|
||||
// Given
|
||||
const onDrillDown =
|
||||
|
||||
@@ -14,11 +14,10 @@ import {
|
||||
getFilteredFindingGroupDelta,
|
||||
isFindingGroupMuted,
|
||||
} from "@/lib/findings-groups";
|
||||
import { FindingGroupRow, ProviderType } from "@/types";
|
||||
import { FindingGroupRow } from "@/types";
|
||||
|
||||
import { DataTableRowActions } from "./data-table-row-actions";
|
||||
import { canMuteFindingGroup } from "./finding-group-selection";
|
||||
import { ImpactedProvidersCell } from "./impacted-providers-cell";
|
||||
import { ImpactedResourcesCell } from "./impacted-resources-cell";
|
||||
import { DeltaValues, NotificationIndicator } from "./notification-indicator";
|
||||
|
||||
@@ -209,19 +208,6 @@ export function getColumnFindingGroups({
|
||||
),
|
||||
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",
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table";
|
||||
import { useInfiniteResources } from "@/hooks/use-infinite-resources";
|
||||
import { cn, hasDateOrScanFilter } from "@/lib";
|
||||
import { cn, hasHistoricalFindingFilter } from "@/lib";
|
||||
import {
|
||||
getFilteredFindingGroupDelta,
|
||||
isFindingGroupMuted,
|
||||
@@ -54,9 +54,9 @@ export function FindingsGroupDrillDown({
|
||||
const [resources, setResources] = useState<FindingResourceRow[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Derive hasDateOrScan from current URL params
|
||||
// Keep drill-down endpoint selection aligned with the grouped findings page.
|
||||
const currentParams = Object.fromEntries(searchParams.entries());
|
||||
const hasDateOrScan = hasDateOrScanFilter(currentParams);
|
||||
const hasHistoricalFilterActive = hasHistoricalFindingFilter(currentParams);
|
||||
|
||||
// Extract filter params from search params
|
||||
const filters: Record<string, string> = {};
|
||||
@@ -88,7 +88,7 @@ export function FindingsGroupDrillDown({
|
||||
|
||||
const { sentinelRef, refresh, loadMore, totalCount } = useInfiniteResources({
|
||||
checkId: group.checkId,
|
||||
hasDateOrScanFilter: hasDateOrScan,
|
||||
hasDateOrScanFilter: hasHistoricalFilterActive,
|
||||
filters,
|
||||
onSetResources: handleSetResources,
|
||||
onAppendResources: handleAppendResources,
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useRef, useState } from "react";
|
||||
|
||||
import { 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";
|
||||
@@ -34,11 +33,15 @@ function buildMuteLabel(groupCount: number, resourceCount: number): string {
|
||||
interface FindingsGroupTableProps {
|
||||
data: FindingGroupRow[];
|
||||
metadata?: MetaDataProps;
|
||||
resolvedFilters: Record<string, string>;
|
||||
hasHistoricalData: boolean;
|
||||
}
|
||||
|
||||
export function FindingsGroupTable({
|
||||
data,
|
||||
metadata,
|
||||
resolvedFilters,
|
||||
hasHistoricalData,
|
||||
}: FindingsGroupTableProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -59,15 +62,7 @@ export function FindingsGroupTable({
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
const filters = resolvedFilters;
|
||||
|
||||
// Get selected group check IDs. When the expanded group has individual resource
|
||||
// selections, exclude it from group-level mute targets — the resource-level
|
||||
@@ -119,7 +114,7 @@ export function FindingsGroupTable({
|
||||
resolveFindingIdsByVisibleGroupResources({
|
||||
checkId,
|
||||
filters,
|
||||
hasDateOrScanFilter: hasDateOrScan,
|
||||
hasDateOrScanFilter: hasHistoricalData,
|
||||
resourceSearch:
|
||||
checkId === expandedCheckId && resourceSearch
|
||||
? resourceSearch
|
||||
@@ -185,6 +180,8 @@ export function FindingsGroupTable({
|
||||
ref={inlineRef}
|
||||
key={`${group.checkId}|${searchParams.toString()}|${resourceSearch}`}
|
||||
group={expandedGroup}
|
||||
resolvedFilters={resolvedFilters}
|
||||
hasHistoricalData={hasHistoricalData}
|
||||
resourceSearch={resourceSearch}
|
||||
columnCount={columns.length}
|
||||
onResourceSelectionChange={setResourceSelection}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -11,7 +11,6 @@ export * from "./findings-selection-context";
|
||||
// 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";
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} 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 { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
@@ -17,7 +16,6 @@ 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";
|
||||
@@ -41,6 +39,8 @@ export interface InlineResourceContainerHandle {
|
||||
|
||||
interface InlineResourceContainerProps {
|
||||
group: FindingGroupRow;
|
||||
resolvedFilters: Record<string, string>;
|
||||
hasHistoricalData: boolean;
|
||||
resourceSearch: string;
|
||||
columnCount: number;
|
||||
/** Called with selected finding IDs (real UUIDs) for parent-level mute */
|
||||
@@ -132,12 +132,13 @@ function ResourceSkeletonRow({
|
||||
|
||||
export function InlineResourceContainer({
|
||||
group,
|
||||
resolvedFilters,
|
||||
hasHistoricalData,
|
||||
resourceSearch,
|
||||
columnCount,
|
||||
onResourceSelectionChange,
|
||||
ref,
|
||||
}: InlineResourceContainerProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [resources, setResources] = useState<FindingResourceRow[]>([]);
|
||||
@@ -155,17 +156,7 @@ export function InlineResourceContainer({
|
||||
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;
|
||||
}
|
||||
});
|
||||
const filters: Record<string, string> = { ...resolvedFilters };
|
||||
if (resourceSearch) {
|
||||
filters["filter[name__icontains]"] = resourceSearch;
|
||||
}
|
||||
@@ -202,7 +193,7 @@ export function InlineResourceContainer({
|
||||
|
||||
const { sentinelRef, refresh, loadMore, totalCount } = useInfiniteResources({
|
||||
checkId: group.checkId,
|
||||
hasDateOrScanFilter: hasDateOrScan,
|
||||
hasDateOrScanFilter: hasHistoricalData,
|
||||
filters,
|
||||
onSetResources: handleSetResources,
|
||||
onAppendResources: handleAppendResources,
|
||||
|
||||
@@ -12,11 +12,13 @@ const {
|
||||
mockGetComplianceIcon,
|
||||
mockGetCompliancesOverview,
|
||||
mockWindowOpen,
|
||||
mockClipboardWriteText,
|
||||
mockSearchParamsState,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetComplianceIcon: vi.fn((_: string) => null as string | null),
|
||||
mockGetCompliancesOverview: vi.fn(),
|
||||
mockWindowOpen: vi.fn(),
|
||||
mockClipboardWriteText: vi.fn(),
|
||||
mockSearchParamsState: { value: "" },
|
||||
}));
|
||||
|
||||
@@ -175,6 +177,29 @@ vi.mock("@/components/findings/markdown-container", () => ({
|
||||
MarkdownContainer: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shared/query-code-editor", () => ({
|
||||
QueryCodeEditor: ({
|
||||
ariaLabel,
|
||||
value,
|
||||
copyValue,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
value: string;
|
||||
copyValue?: string;
|
||||
}) => (
|
||||
<div data-testid="query-code-editor" data-aria-label={ariaLabel}>
|
||||
<span>{ariaLabel}</span>
|
||||
<span>{value}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => mockClipboardWriteText(copyValue ?? value)}
|
||||
>
|
||||
Copy editor code
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/compliances", () => ({
|
||||
getCompliancesOverview: mockGetCompliancesOverview,
|
||||
}));
|
||||
@@ -457,6 +482,36 @@ describe("ResourceDetailDrawerContent — Fix 2: Remediation heading labels", ()
|
||||
// Then — CLI Command label must remain
|
||||
expect(allText).toContain("CLI Command");
|
||||
});
|
||||
|
||||
it("should render remediation snippets with the shared code editor and copy CLI without the visual prompt", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={checkMetaWithCommands}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const editors = screen.getAllByTestId("query-code-editor");
|
||||
await user.click(
|
||||
within(editors[0]).getByRole("button", { name: "Copy editor code" }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(editors).toHaveLength(3);
|
||||
expect(mockClipboardWriteText).toHaveBeenCalledWith("aws s3 ...");
|
||||
expect(screen.getByText("$ aws s3 ...")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { EventsTimeline } from "@/components/shared/events-timeline/events-timeline";
|
||||
import { QueryCodeEditor } from "@/components/shared/query-code-editor";
|
||||
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";
|
||||
@@ -80,6 +81,29 @@ function stripCodeFences(code: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function renderRemediationCodeBlock({
|
||||
label,
|
||||
value,
|
||||
copyValue,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
copyValue?: string;
|
||||
}) {
|
||||
return (
|
||||
<QueryCodeEditor
|
||||
ariaLabel={label}
|
||||
language="plainText"
|
||||
value={value}
|
||||
copyValue={copyValue}
|
||||
editable={false}
|
||||
minHeight={96}
|
||||
showCopyButton
|
||||
onChange={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeComplianceFrameworkName(framework: string): string {
|
||||
return framework
|
||||
.trim()
|
||||
@@ -715,47 +739,35 @@ export function ResourceDetailDrawerContent({
|
||||
|
||||
{checkMeta.remediation.code.cli && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
CLI Command:
|
||||
</span>
|
||||
<CodeSnippet
|
||||
value={`$ ${stripCodeFences(checkMeta.remediation.code.cli)}`}
|
||||
multiline
|
||||
transparent
|
||||
className="max-w-full text-sm"
|
||||
/>
|
||||
{renderRemediationCodeBlock({
|
||||
label: "CLI Command",
|
||||
value: `$ ${stripCodeFences(checkMeta.remediation.code.cli)}`,
|
||||
copyValue: stripCodeFences(
|
||||
checkMeta.remediation.code.cli,
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkMeta.remediation.code.terraform && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
Terraform:
|
||||
</span>
|
||||
<CodeSnippet
|
||||
value={stripCodeFences(
|
||||
{renderRemediationCodeBlock({
|
||||
label: "Terraform",
|
||||
value: stripCodeFences(
|
||||
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:
|
||||
</span>
|
||||
<CodeSnippet
|
||||
value={stripCodeFences(
|
||||
{renderRemediationCodeBlock({
|
||||
label: "CloudFormation",
|
||||
value: stripCodeFences(
|
||||
checkMeta.remediation.code.nativeiac,
|
||||
)}
|
||||
multiline
|
||||
transparent
|
||||
className="max-w-full text-sm"
|
||||
/>
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -377,4 +377,71 @@ describe("useResourceDetailDrawer — other findings filtering", () => {
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should clear the previous resource findings when navigation to the next resource fails", async () => {
|
||||
// Given
|
||||
const resources = [
|
||||
makeResource({
|
||||
id: "row-1",
|
||||
findingId: "finding-1",
|
||||
resourceUid: "arn:aws:s3:::first-bucket",
|
||||
resourceName: "first-bucket",
|
||||
}),
|
||||
makeResource({
|
||||
id: "row-2",
|
||||
findingId: "finding-2",
|
||||
resourceUid: "arn:aws:s3:::second-bucket",
|
||||
resourceName: "second-bucket",
|
||||
}),
|
||||
];
|
||||
|
||||
getLatestFindingsByResourceUidMock.mockImplementation(
|
||||
async ({ resourceUid }: { resourceUid: string }) => {
|
||||
if (resourceUid.includes("second")) {
|
||||
throw new Error("Fetch failed");
|
||||
}
|
||||
|
||||
return { data: [resourceUid] };
|
||||
},
|
||||
);
|
||||
|
||||
adaptFindingsByResourceResponseMock.mockImplementation(
|
||||
(response: { data: string[] }) => [
|
||||
makeDrawerFinding({
|
||||
id: response.data[0].includes("first") ? "finding-1" : "finding-2",
|
||||
resourceUid: response.data[0],
|
||||
resourceName: response.data[0].includes("first")
|
||||
? "first-bucket"
|
||||
: "second-bucket",
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useResourceDetailDrawer({
|
||||
resources,
|
||||
checkId: "s3_check",
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.openDrawer(0);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.currentFinding?.resourceUid).toBe(
|
||||
"arn:aws:s3:::first-bucket",
|
||||
);
|
||||
|
||||
// When
|
||||
await act(async () => {
|
||||
result.current.navigateNext();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.current.currentIndex).toBe(1);
|
||||
expect(result.current.currentFinding).toBeNull();
|
||||
expect(result.current.otherFindings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -181,7 +181,7 @@ export function useResourceDetailDrawer({
|
||||
} 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
|
||||
setFindings([]);
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
@@ -212,6 +212,7 @@ export function useResourceDetailDrawer({
|
||||
if (!resource) return;
|
||||
cacheRef.current.delete(resource.resourceUid);
|
||||
startNavigation();
|
||||
setFindings([]);
|
||||
fetchFindings(resource.resourceUid);
|
||||
};
|
||||
|
||||
@@ -221,6 +222,7 @@ export function useResourceDetailDrawer({
|
||||
|
||||
setCurrentIndex(index);
|
||||
startNavigation();
|
||||
setFindings([]);
|
||||
fetchFindings(resource.resourceUid);
|
||||
};
|
||||
|
||||
|
||||
@@ -121,7 +121,9 @@ export const ProvidersFilters = ({
|
||||
onValuesChange={(values) => pushDropdownFilter(filter, values)}
|
||||
>
|
||||
<MultiSelectTrigger size="default">
|
||||
<MultiSelectValue placeholder={filter.labelCheckboxGroup} />
|
||||
<MultiSelectValue
|
||||
placeholder={`All ${filter.labelCheckboxGroup}`}
|
||||
/>
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent search={false}>
|
||||
<MultiSelectSelectAll>Select All</MultiSelectSelectAll>
|
||||
|
||||
@@ -1,48 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/shadcn";
|
||||
import { ResourceProps } from "@/types";
|
||||
|
||||
import { ResourceDetail } from "./table/resource-detail";
|
||||
import { ResourceDetailContent } from "./table/resource-detail-content";
|
||||
|
||||
interface ResourceDetailsSheetProps {
|
||||
resource: ResourceProps;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const ResourceDetailsSheet = ({
|
||||
resource,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ResourceDetailsSheetProps) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete("resourceId");
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={true} onOpenChange={handleOpenChange}>
|
||||
<SheetContent className="minimal-scrollbar 3xl:w-1/3 h-full w-full overflow-x-hidden overflow-y-auto p-6 pt-10 outline-none md:w-1/2 md:max-w-none">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="sr-only">Resource Details</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
View the resource details
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<ResourceDetail resourceDetails={resource} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<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 Details</DrawerTitle>
|
||||
<DrawerDescription>View the resource details</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 && <ResourceDetailContent resourceDetails={resource} />}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useState } from "react";
|
||||
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
|
||||
import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector";
|
||||
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
|
||||
import { CustomDatePicker } from "@/components/filters/custom-date-picker";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { ExpandableSection } from "@/components/ui/expandable-section";
|
||||
import { DataTableFilterCustom } from "@/components/ui/table";
|
||||
@@ -17,6 +16,7 @@ interface ResourcesFiltersProps {
|
||||
providers: ProviderProps[];
|
||||
uniqueRegions: string[];
|
||||
uniqueServices: string[];
|
||||
uniqueResourceTypes: string[];
|
||||
uniqueGroups: string[];
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export const ResourcesFilters = ({
|
||||
providers,
|
||||
uniqueRegions,
|
||||
uniqueServices,
|
||||
uniqueResourceTypes,
|
||||
uniqueGroups,
|
||||
}: ResourcesFiltersProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
@@ -32,22 +33,28 @@ export const ResourcesFilters = ({
|
||||
const customFilters = [
|
||||
{
|
||||
key: "region__in",
|
||||
labelCheckboxGroup: "Region",
|
||||
labelCheckboxGroup: "Regions",
|
||||
values: uniqueRegions,
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
key: "service__in",
|
||||
labelCheckboxGroup: "Service",
|
||||
labelCheckboxGroup: "Services",
|
||||
values: uniqueServices,
|
||||
index: 2,
|
||||
},
|
||||
{
|
||||
key: "type__in",
|
||||
labelCheckboxGroup: "Types",
|
||||
values: uniqueResourceTypes,
|
||||
index: 3,
|
||||
},
|
||||
{
|
||||
key: "groups__in",
|
||||
labelCheckboxGroup: "Group",
|
||||
labelCheckboxGroup: "Groups",
|
||||
values: uniqueGroups,
|
||||
labelFormatter: getGroupLabel,
|
||||
index: 3,
|
||||
index: 4,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -81,11 +88,7 @@ export const ResourcesFilters = ({
|
||||
{/* Expandable filters section */}
|
||||
{hasCustomFilters && (
|
||||
<ExpandableSection isExpanded={isExpanded}>
|
||||
<DataTableFilterCustom
|
||||
filters={customFilters}
|
||||
prependElement={<CustomDatePicker />}
|
||||
hideClearButton
|
||||
/>
|
||||
<DataTableFilterCustom filters={customFilters} hideClearButton />
|
||||
</ExpandableSection>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { AlertTriangle, Eye } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle, Container, Eye } from "lucide-react";
|
||||
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
|
||||
import { EntityInfo } from "@/components/ui/entities";
|
||||
import { DataTableColumnHeader } from "@/components/ui/table";
|
||||
import { getGroupLabel } from "@/lib/categories";
|
||||
import { getRegionFlag } from "@/lib/region-flags";
|
||||
import { ProviderType, ResourceProps } from "@/types";
|
||||
|
||||
import { ResourceDetail } from "./resource-detail";
|
||||
|
||||
const getResourceData = (
|
||||
row: { original: ResourceProps },
|
||||
field: keyof ResourceProps["attributes"],
|
||||
@@ -33,31 +30,25 @@ const getProviderData = (
|
||||
);
|
||||
};
|
||||
|
||||
// Component for resource name that opens the detail drawer
|
||||
const ResourceNameCell = ({ row }: { row: { original: ResourceProps } }) => {
|
||||
const resourceName = row.original.attributes?.name;
|
||||
const resourceUid = row.original.attributes?.uid;
|
||||
const displayName =
|
||||
const entityAlias =
|
||||
typeof resourceName === "string" && resourceName.trim().length > 0
|
||||
? resourceName
|
||||
: "Unnamed resource";
|
||||
|
||||
// Note: We don't use defaultOpen here because ResourceDetailsSheet (rendered at page level)
|
||||
// already handles opening the drawer when resourceId is in the URL. Using defaultOpen={true}
|
||||
// here would cause duplicate drawers to render.
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ResourceDetail
|
||||
resourceDetails={row.original}
|
||||
trigger={
|
||||
<div className="max-w-[200px]">
|
||||
<p className="text-text-neutral-primary hover:text-button-tertiary cursor-pointer text-left text-sm break-words whitespace-normal hover:underline">
|
||||
{displayName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-w-[240px]">
|
||||
<EntityInfo
|
||||
nameIcon={<Container className="size-4" />}
|
||||
entityAlias={entityAlias}
|
||||
entityId={
|
||||
resourceUid && typeof resourceUid === "string"
|
||||
? resourceUid
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{resourceUid && <CodeSnippet value={resourceUid} hideCode />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -80,163 +71,169 @@ const FailedFindingsBadge = ({ count }: { count: number }) => {
|
||||
);
|
||||
};
|
||||
|
||||
// Row actions dropdown
|
||||
const ResourceRowActions = ({ row }: { row: { original: ResourceProps } }) => {
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
interface GetColumnResourcesOptions {
|
||||
onViewDetails: (resource: ResourceProps) => void;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-end">
|
||||
<ActionDropdown ariaLabel="Resource actions">
|
||||
<ActionDropdownItem
|
||||
icon={<Eye className="size-5" />}
|
||||
label="View Details"
|
||||
onSelect={() => setIsDrawerOpen(true)}
|
||||
export function getColumnResources({
|
||||
onViewDetails,
|
||||
}: GetColumnResourcesOptions): ColumnDef<ResourceProps>[] {
|
||||
return [
|
||||
// Name column
|
||||
{
|
||||
accessorKey: "resourceName",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Name" />
|
||||
),
|
||||
cell: ({ row }) => <ResourceNameCell row={row} />,
|
||||
enableSorting: false,
|
||||
},
|
||||
// Provider Account column
|
||||
{
|
||||
accessorKey: "provider",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Cloud Provider" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const provider = getProviderData(row, "provider");
|
||||
const alias = getProviderData(row, "alias");
|
||||
const uid = getProviderData(row, "uid");
|
||||
return (
|
||||
<EntityInfo
|
||||
cloudProvider={provider as ProviderType}
|
||||
entityAlias={alias && typeof alias === "string" ? alias : undefined}
|
||||
entityId={uid && typeof uid === "string" ? uid : undefined}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
|
||||
<ResourceDetail
|
||||
resourceDetails={row.original}
|
||||
open={isDrawerOpen}
|
||||
onOpenChange={setIsDrawerOpen}
|
||||
trigger={<span className="hidden" />}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Column definitions for resources table
|
||||
export const ColumnResources: ColumnDef<ResourceProps>[] = [
|
||||
// Name column
|
||||
{
|
||||
accessorKey: "resourceName",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Name" />
|
||||
),
|
||||
cell: ({ row }) => <ResourceNameCell row={row} />,
|
||||
enableSorting: false,
|
||||
},
|
||||
// Provider Account column
|
||||
{
|
||||
accessorKey: "provider",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Provider Account" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const provider = getProviderData(row, "provider");
|
||||
const alias = getProviderData(row, "alias");
|
||||
const uid = getProviderData(row, "uid");
|
||||
return (
|
||||
<EntityInfo
|
||||
cloudProvider={provider as ProviderType}
|
||||
entityAlias={alias && typeof alias === "string" ? alias : undefined}
|
||||
entityId={uid && typeof uid === "string" ? uid : undefined}
|
||||
/>
|
||||
);
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
// Failed Findings column
|
||||
{
|
||||
accessorKey: "failedFindings",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Failed Findings" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const failedFindingsCount = getResourceData(
|
||||
row,
|
||||
"failed_findings_count",
|
||||
) as number;
|
||||
// Failed Findings column
|
||||
{
|
||||
accessorKey: "failedFindings",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Failed Findings" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const failedFindingsCount = getResourceData(
|
||||
row,
|
||||
"failed_findings_count",
|
||||
) as number;
|
||||
|
||||
return <FailedFindingsBadge count={failedFindingsCount ?? 0} />;
|
||||
return <FailedFindingsBadge count={failedFindingsCount ?? 0} />;
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
// Group column
|
||||
{
|
||||
accessorKey: "groups",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Group" param="groups" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const groups = getResourceData(row, "groups") as string[] | null;
|
||||
// Group column
|
||||
{
|
||||
accessorKey: "groups",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Group" param="groups" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const groups = getResourceData(row, "groups") as string[] | null;
|
||||
|
||||
if (!groups || groups.length === 0) {
|
||||
return <p className="text-text-neutral-primary text-sm">-</p>;
|
||||
}
|
||||
if (!groups || groups.length === 0) {
|
||||
return <p className="text-text-neutral-primary text-sm">-</p>;
|
||||
}
|
||||
|
||||
const displayLabel = getGroupLabel(groups[0]);
|
||||
const extraCount = groups.length - 1;
|
||||
const displayLabel = getGroupLabel(groups[0]);
|
||||
const extraCount = groups.length - 1;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="text-text-neutral-primary max-w-[120px] truncate text-sm">
|
||||
{displayLabel}
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="text-text-neutral-primary max-w-[120px] truncate text-sm">
|
||||
{displayLabel}
|
||||
</p>
|
||||
{extraCount > 0 && (
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
+{extraCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
// Type column
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Type" param="type" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const type = getResourceData(row, "type");
|
||||
|
||||
return (
|
||||
<p className="text-text-neutral-primary max-w-[150px] truncate text-sm">
|
||||
{typeof type === "string" ? type : "-"}
|
||||
</p>
|
||||
{extraCount > 0 && (
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
+{extraCount}
|
||||
</span>
|
||||
)}
|
||||
);
|
||||
},
|
||||
},
|
||||
// Region column
|
||||
{
|
||||
accessorKey: "region",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Region" param="region" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const region = getResourceData(row, "region");
|
||||
const regionText = typeof region === "string" ? region : "-";
|
||||
const regionFlag =
|
||||
typeof region === "string" ? getRegionFlag(region) : "";
|
||||
|
||||
return (
|
||||
<span className="text-text-neutral-primary flex max-w-[140px] items-center gap-1.5 truncate text-sm">
|
||||
{regionFlag && (
|
||||
<span className="translate-y-px text-base leading-none">
|
||||
{regionFlag}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{regionText}</span>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
// Service column
|
||||
{
|
||||
accessorKey: "service",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title="Service"
|
||||
param="service"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const service = getResourceData(row, "service");
|
||||
|
||||
return (
|
||||
<p className="text-text-neutral-primary max-w-[150px] truncate text-sm">
|
||||
{typeof service === "string" ? service : "-"}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
},
|
||||
// Actions column
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <div className="w-10" />,
|
||||
cell: ({ row }) => (
|
||||
<div
|
||||
className="flex items-center justify-end"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ActionDropdown ariaLabel="Resource actions">
|
||||
<ActionDropdownItem
|
||||
icon={<Eye className="size-5" />}
|
||||
label="View Details"
|
||||
onSelect={() => onViewDetails(row.original)}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
);
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
// Type column
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Type" param="type" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const type = getResourceData(row, "type");
|
||||
|
||||
return (
|
||||
<p className="text-text-neutral-primary max-w-[150px] truncate text-sm">
|
||||
{typeof type === "string" ? type : "-"}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
},
|
||||
// Region column
|
||||
{
|
||||
accessorKey: "region",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Region" param="region" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const region = getResourceData(row, "region");
|
||||
|
||||
return (
|
||||
<p className="text-text-neutral-primary max-w-[120px] truncate text-sm">
|
||||
{typeof region === "string" ? region : "-"}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
},
|
||||
// Service column
|
||||
{
|
||||
accessorKey: "service",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Service" param="service" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const service = getResourceData(row, "service");
|
||||
|
||||
return (
|
||||
<p className="text-text-neutral-primary max-w-[150px] truncate text-sm">
|
||||
{typeof service === "string" ? service : "-"}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
},
|
||||
// Actions column
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <div className="w-10" />,
|
||||
cell: ({ row }) => <ResourceRowActions row={row} />,
|
||||
enableSorting: false,
|
||||
},
|
||||
];
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { Row, RowSelectionState } from "@tanstack/react-table";
|
||||
import { Check, Copy, ExternalLink, Link, Loader2 } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
Check,
|
||||
Container,
|
||||
Copy,
|
||||
CornerDownRight,
|
||||
ExternalLink,
|
||||
Link,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { getFindingById, getLatestFindings } from "@/actions/findings";
|
||||
import { listOrganizationsSafe } from "@/actions/organizations/organizations";
|
||||
import { getResourceById } from "@/actions/resources";
|
||||
import { FloatingMuteButton } from "@/components/findings/floating-mute-button";
|
||||
import { FindingDetail } from "@/components/findings/table/finding-detail";
|
||||
import {
|
||||
Card,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
@@ -18,30 +28,61 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn";
|
||||
import {
|
||||
InfoField,
|
||||
InfoTooltip,
|
||||
} from "@/components/shadcn/info-field/info-field";
|
||||
import { EventsTimeline } from "@/components/shared/events-timeline/events-timeline";
|
||||
import { BreadcrumbNavigation, CustomBreadcrumbItem } from "@/components/ui";
|
||||
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
|
||||
import {
|
||||
DateWithTime,
|
||||
getProviderLogo,
|
||||
InfoField,
|
||||
} from "@/components/ui/entities";
|
||||
import { DateWithTime } from "@/components/ui/entities/date-with-time";
|
||||
import { EntityInfo } from "@/components/ui/entities/entity-info";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { createDict } from "@/lib";
|
||||
import { getGroupLabel } from "@/lib/categories";
|
||||
import { buildGitFileUrl } from "@/lib/iac-utils";
|
||||
import { getRegionFlag } from "@/lib/region-flags";
|
||||
import {
|
||||
FindingProps,
|
||||
MetaDataProps,
|
||||
ProviderType,
|
||||
ResourceProps,
|
||||
} from "@/types";
|
||||
import { OrganizationResource } from "@/types/organizations";
|
||||
|
||||
import {
|
||||
getResourceFindingsColumns,
|
||||
ResourceFinding,
|
||||
} from "./resource-findings-columns";
|
||||
|
||||
function useProviderOrganization(
|
||||
providerId: string,
|
||||
providerType: string,
|
||||
): OrganizationResource | null {
|
||||
const [org, setOrg] = useState<OrganizationResource | null>(null);
|
||||
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCloudEnv || providerType !== "aws") {
|
||||
setOrg(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadOrg = async () => {
|
||||
const response = await listOrganizationsSafe();
|
||||
const found = response.data.find((o: OrganizationResource) =>
|
||||
o.relationships?.providers?.data?.some(
|
||||
(p: { id: string }) => p.id === providerId,
|
||||
),
|
||||
);
|
||||
setOrg(found ?? null);
|
||||
};
|
||||
|
||||
loadOrg();
|
||||
}, [isCloudEnv, providerType, providerId]);
|
||||
|
||||
return org;
|
||||
}
|
||||
|
||||
const renderValue = (value: string | null | undefined) => {
|
||||
return value && value.trim() !== "" ? value : "-";
|
||||
};
|
||||
@@ -120,24 +161,27 @@ export const ResourceDetailContent = ({
|
||||
);
|
||||
const [findingDetailLoading, setFindingDetailLoading] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [activeTab, setActiveTab] = useState("findings");
|
||||
const [metadataCopied, setMetadataCopied] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const findingFetchRef = useRef<AbortController | null>(null);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const resource = resourceDetails;
|
||||
const resourceId = resource.id;
|
||||
const attributes = resource.attributes;
|
||||
const providerData = resource.relationships.provider.data.attributes;
|
||||
const providerId = resource.relationships.provider.data.id;
|
||||
const providerOrg = useProviderOrganization(
|
||||
providerId,
|
||||
providerData.provider,
|
||||
);
|
||||
|
||||
// Reset to overview tab when switching resources
|
||||
useEffect(() => {
|
||||
setActiveTab("overview");
|
||||
setActiveTab("findings");
|
||||
}, [resourceId]);
|
||||
|
||||
// Cleanup abort controller on unmount
|
||||
@@ -148,9 +192,7 @@ export const ResourceDetailContent = ({
|
||||
}, []);
|
||||
|
||||
const copyResourceUrl = () => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("resourceId", resourceId);
|
||||
const url = `${window.location.origin}${pathname}?${params.toString()}`;
|
||||
const url = `${window.location.origin}/resources?resourceId=${resourceId}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
};
|
||||
|
||||
@@ -324,6 +366,21 @@ export const ResourceDetailContent = ({
|
||||
|
||||
const findingTitle =
|
||||
findingDetails?.attributes?.check_metadata?.checktitle || "Finding Detail";
|
||||
const resourceName =
|
||||
typeof attributes.name === "string" && attributes.name.trim().length > 0
|
||||
? attributes.name
|
||||
: "Unnamed resource";
|
||||
const resourceRegion = renderValue(attributes.region);
|
||||
const regionFlag = getRegionFlag(resourceRegion);
|
||||
const groupValue =
|
||||
attributes.groups && attributes.groups.length > 0
|
||||
? attributes.groups.map(getGroupLabel).join(", ")
|
||||
: "-";
|
||||
const parsedMetadata = parseMetadata(attributes.metadata);
|
||||
const hasMetadata =
|
||||
parsedMetadata !== null && Object.entries(parsedMetadata).length > 0;
|
||||
const tagEntries = Object.entries(resourceTags);
|
||||
const hasTags = tagEntries.length > 0;
|
||||
|
||||
// Content when viewing a finding detail (breadcrumb navigation)
|
||||
if (selectedFindingId) {
|
||||
@@ -354,17 +411,12 @@ export const ResourceDetailContent = ({
|
||||
|
||||
// Main resource content
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-4 rounded-lg">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="shrink-0">
|
||||
{getProviderLogo(providerData.provider as ProviderType)}
|
||||
</div>
|
||||
|
||||
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-text-neutral-primary line-clamp-2 text-lg leading-tight font-medium">
|
||||
{renderValue(attributes.name)}
|
||||
{resourceName}
|
||||
</h2>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -398,159 +450,219 @@ export const ResourceDetailContent = ({
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-text-neutral-tertiary text-sm">
|
||||
<span className="text-text-neutral-secondary mr-1">
|
||||
Last Updated:
|
||||
</span>
|
||||
<DateWithTime inline dateTime={attributes.updated_at || "-"} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="findings">
|
||||
Findings {totalFindings > 0 && `(${totalFindings})`}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="events">Events</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Overview Tab */}
|
||||
<TabsContent value="overview" className="flex flex-col gap-4">
|
||||
<InfoField label="Resource UID" variant="simple">
|
||||
<CodeSnippet value={attributes.uid} className="max-w-full" />
|
||||
</InfoField>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Name">{renderValue(attributes.name)}</InfoField>
|
||||
<InfoField label="Type">{renderValue(attributes.type)}</InfoField>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Group">
|
||||
{attributes.groups && attributes.groups.length > 0
|
||||
? attributes.groups.map(getGroupLabel).join(", ")
|
||||
: "-"}
|
||||
</InfoField>
|
||||
<InfoField label="Service">
|
||||
{renderValue(attributes.service)}
|
||||
</InfoField>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Region">
|
||||
{renderValue(attributes.region)}
|
||||
</InfoField>
|
||||
<InfoField label="Partition">
|
||||
{renderValue(attributes.partition)}
|
||||
</InfoField>
|
||||
</div>
|
||||
<InfoField label="Details" variant="simple">
|
||||
{renderValue(attributes.details)}
|
||||
</InfoField>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Created At">
|
||||
<DateWithTime inline dateTime={attributes.inserted_at} />
|
||||
</InfoField>
|
||||
<InfoField label="Last Updated">
|
||||
<DateWithTime inline dateTime={attributes.updated_at} />
|
||||
</InfoField>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const parsedMetadata = parseMetadata(attributes.metadata);
|
||||
return parsedMetadata &&
|
||||
Object.entries(parsedMetadata).length > 0 ? (
|
||||
<InfoField label="Metadata" variant="simple">
|
||||
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary relative w-full rounded-lg border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyMetadata(parsedMetadata)}
|
||||
className="text-text-neutral-secondary hover:text-text-neutral-primary absolute top-2 right-2 z-10 cursor-pointer transition-colors"
|
||||
aria-label="Copy metadata to clipboard"
|
||||
>
|
||||
{metadataCopied ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<pre className="minimal-scrollbar mr-10 max-h-[200px] overflow-auto p-3 text-xs break-words whitespace-pre-wrap">
|
||||
{JSON.stringify(parsedMetadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</InfoField>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
{resourceTags && Object.entries(resourceTags).length > 0 ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h4 className="text-text-neutral-secondary text-sm font-bold">
|
||||
Tags
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{Object.entries(resourceTags).map(([key, value]) => (
|
||||
<InfoField key={key} label={key}>
|
||||
{renderValue(value)}
|
||||
</InfoField>
|
||||
))}
|
||||
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex min-h-0 flex-1 flex-col gap-4 overflow-hidden rounded-lg border p-4">
|
||||
<div className="grid min-w-0 grid-cols-1 gap-4 md:grid-cols-4 md:gap-x-8 md:gap-y-4">
|
||||
{providerOrg ? (
|
||||
<div className="col-span-2 flex flex-col gap-1">
|
||||
<EntityInfo
|
||||
cloudProvider="aws"
|
||||
entityAlias={providerOrg.attributes.name}
|
||||
entityId={providerOrg.attributes.external_id}
|
||||
/>
|
||||
<div className="flex items-start pl-6">
|
||||
<CornerDownRight className="text-text-neutral-tertiary mt-1 mr-2 size-4 shrink-0" />
|
||||
<EntityInfo
|
||||
cloudProvider="aws"
|
||||
entityAlias={providerData.alias ?? undefined}
|
||||
entityId={providerData.uid}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
{/* Findings Tab */}
|
||||
<TabsContent value="findings" className="flex flex-col gap-4">
|
||||
{findingsLoading && !hasInitiallyLoaded ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Loading findings...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={failedFindings}
|
||||
metadata={findingsMetadata ?? undefined}
|
||||
showSearch
|
||||
disableScroll
|
||||
enableRowSelection
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
getRowCanSelect={getRowCanSelect}
|
||||
controlledSearch={searchQuery}
|
||||
onSearchChange={(value) => {
|
||||
setSearchQuery(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
controlledPage={currentPage}
|
||||
controlledPageSize={pageSize}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
isLoading={findingsLoading}
|
||||
/>
|
||||
{selectedFindingIds.length > 0 && (
|
||||
<FloatingMuteButton
|
||||
selectedCount={selectedFindingIds.length}
|
||||
selectedFindingIds={selectedFindingIds}
|
||||
onComplete={handleMuteComplete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<EntityInfo
|
||||
cloudProvider={providerData.provider as ProviderType}
|
||||
entityAlias={providerData.alias ?? undefined}
|
||||
entityId={providerData.uid}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
<div className={providerOrg ? "self-end" : undefined}>
|
||||
<EntityInfo
|
||||
nameIcon={<Container className="size-4" />}
|
||||
entityAlias={resourceName}
|
||||
entityId={attributes.uid}
|
||||
/>
|
||||
</div>
|
||||
<InfoField
|
||||
label="Service"
|
||||
variant="compact"
|
||||
className={providerOrg ? "self-end" : undefined}
|
||||
>
|
||||
{renderValue(attributes.service)}
|
||||
</InfoField>
|
||||
<InfoField label="Region" variant="compact">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{regionFlag && (
|
||||
<span className="translate-y-px text-base leading-none">
|
||||
{regionFlag}
|
||||
</span>
|
||||
)}
|
||||
{resourceRegion}
|
||||
</span>
|
||||
</InfoField>
|
||||
|
||||
{/* Events Tab */}
|
||||
<TabsContent value="events" className="flex flex-col gap-4">
|
||||
<EventsTimeline
|
||||
resourceId={resourceId}
|
||||
isAwsProvider={providerData.provider === "aws"}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<InfoField label="Type" variant="compact">
|
||||
{renderValue(attributes.type)}
|
||||
</InfoField>
|
||||
<InfoField label="Group" variant="compact">
|
||||
{groupValue}
|
||||
</InfoField>
|
||||
<InfoField label="Partition" variant="compact">
|
||||
{renderValue(attributes.partition)}
|
||||
</InfoField>
|
||||
|
||||
<InfoField label="Created At" variant="compact">
|
||||
<DateWithTime inline dateTime={attributes.inserted_at || "-"} />
|
||||
</InfoField>
|
||||
<InfoField label="Last Updated" variant="compact">
|
||||
<DateWithTime inline dateTime={attributes.updated_at || "-"} />
|
||||
</InfoField>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="mt-2 flex min-h-0 w-full flex-1 flex-col"
|
||||
>
|
||||
<div className="mb-4 flex shrink-0 items-center justify-between">
|
||||
<TabsList>
|
||||
<TabsTrigger value="findings">
|
||||
<span className="flex items-center gap-1">
|
||||
Findings {totalFindings > 0 && `(${totalFindings})`}
|
||||
<InfoTooltip content="This table also includes muted findings" />
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="metadata">Metadata</TabsTrigger>
|
||||
<TabsTrigger value="tags">Tags</TabsTrigger>
|
||||
<TabsTrigger value="events">Events</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="minimal-scrollbar min-h-0 flex-1 overflow-y-auto">
|
||||
<TabsContent value="findings" className="flex flex-col gap-4">
|
||||
{findingsLoading && !hasInitiallyLoaded ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Loading findings...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={failedFindings}
|
||||
metadata={findingsMetadata ?? undefined}
|
||||
showSearch
|
||||
disableScroll
|
||||
enableRowSelection
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
getRowCanSelect={getRowCanSelect}
|
||||
controlledSearch={searchQuery}
|
||||
onSearchChange={(value) => {
|
||||
setSearchQuery(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
controlledPage={currentPage}
|
||||
controlledPageSize={pageSize}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
isLoading={findingsLoading}
|
||||
/>
|
||||
{selectedFindingIds.length > 0 && (
|
||||
<FloatingMuteButton
|
||||
selectedCount={selectedFindingIds.length}
|
||||
selectedFindingIds={selectedFindingIds}
|
||||
onComplete={handleMuteComplete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="metadata" className="flex flex-col gap-4">
|
||||
{attributes.details && attributes.details.trim() !== "" && (
|
||||
<Card variant="inner">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-sm font-semibold">
|
||||
Details:
|
||||
</span>
|
||||
<p className="text-text-neutral-primary text-sm break-words whitespace-pre-wrap">
|
||||
{attributes.details}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{hasMetadata && parsedMetadata && (
|
||||
<Card variant="inner">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-sm font-semibold">
|
||||
Metadata:
|
||||
</span>
|
||||
<div className="border-border-neutral-secondary bg-bg-neutral-secondary relative w-full rounded-lg border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyMetadata(parsedMetadata)}
|
||||
className="text-text-neutral-secondary hover:text-text-neutral-primary absolute top-2 right-2 z-10 cursor-pointer transition-colors"
|
||||
aria-label="Copy metadata to clipboard"
|
||||
>
|
||||
{metadataCopied ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<pre className="minimal-scrollbar mr-10 max-h-[200px] overflow-auto p-3 text-xs break-words whitespace-pre-wrap">
|
||||
{JSON.stringify(parsedMetadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!attributes.details?.trim() && !hasMetadata && (
|
||||
<p className="text-text-neutral-tertiary py-8 text-center text-sm">
|
||||
No metadata available for this resource.
|
||||
</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tags" className="flex flex-col gap-4">
|
||||
{hasTags ? (
|
||||
<Card variant="inner">
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-text-neutral-secondary text-sm font-semibold">
|
||||
Tags:
|
||||
</span>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{tagEntries.map(([key, value]) => (
|
||||
<InfoField key={key} label={key} variant="compact">
|
||||
{renderValue(value)}
|
||||
</InfoField>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<p className="text-text-neutral-tertiary py-8 text-center text-sm">
|
||||
No tags available for this resource.
|
||||
</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="events" className="flex flex-col gap-4">
|
||||
<EventsTimeline
|
||||
resourceId={resourceId}
|
||||
isAwsProvider={providerData.provider === "aws"}
|
||||
/>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,28 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { ResourceDetailsSheet } from "@/components/resources/resource-details-sheet";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { MetaDataProps, ResourceProps } from "@/types";
|
||||
|
||||
import { ColumnResources } from "./column-resources";
|
||||
import { getColumnResources } from "./column-resources";
|
||||
|
||||
interface ResourcesTableWithSelectionProps {
|
||||
data: ResourceProps[];
|
||||
metadata?: MetaDataProps;
|
||||
initialResource?: ResourceProps | null;
|
||||
}
|
||||
|
||||
export function ResourcesTableWithSelection({
|
||||
data,
|
||||
metadata,
|
||||
initialResource = null,
|
||||
}: ResourcesTableWithSelectionProps) {
|
||||
// Ensure data is always an array for safe operations
|
||||
const safeData = data ?? [];
|
||||
|
||||
const [selectedResource, setSelectedResource] =
|
||||
useState<ResourceProps | null>(initialResource);
|
||||
const [drawerOpen, setDrawerOpen] = useState(Boolean(initialResource));
|
||||
|
||||
const openDrawer = (resource: ResourceProps) => {
|
||||
setSelectedResource(resource);
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const columns = getColumnResources({ onViewDetails: openDrawer });
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={ColumnResources}
|
||||
data={safeData}
|
||||
metadata={metadata}
|
||||
showSearch
|
||||
/>
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={safeData}
|
||||
metadata={metadata}
|
||||
showSearch
|
||||
onRowClick={(row) => openDrawer(row.original)}
|
||||
/>
|
||||
{selectedResource && (
|
||||
<ResourceDetailsSheet
|
||||
resource={selectedResource}
|
||||
open={drawerOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDrawerOpen(open);
|
||||
if (!open) setSelectedResource(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
|
||||
import { InfoField } from "@/components/shadcn/info-field/info-field";
|
||||
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
|
||||
import { DateWithTime, EntityInfo, InfoField } from "@/components/ui/entities";
|
||||
import { DateWithTime, EntityInfo } 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";
|
||||
|
||||
@@ -7,6 +7,19 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../tooltip";
|
||||
|
||||
export function InfoTooltip({ content }: { content: string }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex cursor-pointer items-center">
|
||||
<InfoIcon className="text-bg-data-info size-3" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{content}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export const INFO_FIELD_VARIANTS = {
|
||||
default: "default",
|
||||
simple: "simple",
|
||||
@@ -38,16 +51,7 @@ export function InfoField({
|
||||
<span className="flex items-center gap-1">
|
||||
{label}
|
||||
{inline && ":"}
|
||||
{tooltipContent && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex cursor-pointer items-center">
|
||||
<InfoIcon className="text-bg-data-info size-3" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltipContent}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{tooltipContent && <InfoTooltip content={tooltipContent} />}
|
||||
</span>
|
||||
);
|
||||
|
||||
|
||||
107
ui/components/shadcn/select/multiselect.test.tsx
Normal file
107
ui/components/shadcn/select/multiselect.test.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
MultiSelect,
|
||||
MultiSelectContent,
|
||||
MultiSelectItem,
|
||||
MultiSelectTrigger,
|
||||
MultiSelectValue,
|
||||
} from "./multiselect";
|
||||
|
||||
class ResizeObserverMock {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, "ResizeObserver", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: ResizeObserverMock,
|
||||
});
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: () => {},
|
||||
});
|
||||
|
||||
describe("MultiSelect", () => {
|
||||
it("shows preselected labels before the popover opens", () => {
|
||||
// Given
|
||||
render(
|
||||
<MultiSelect values={["aws-prod"]} onValuesChange={() => {}}>
|
||||
<MultiSelectTrigger>
|
||||
<MultiSelectValue placeholder="Select accounts" />
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent search={false}>
|
||||
<MultiSelectItem value="aws-prod">Production AWS</MultiSelectItem>
|
||||
<MultiSelectItem value="azure-dev">Development Azure</MultiSelectItem>
|
||||
</MultiSelectContent>
|
||||
</MultiSelect>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
within(screen.getByRole("combobox")).getByText("Production AWS"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters items without crashing when search is enabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<MultiSelect values={[]} onValuesChange={() => {}}>
|
||||
<MultiSelectTrigger>
|
||||
<MultiSelectValue placeholder="Select accounts" />
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent
|
||||
search={{
|
||||
placeholder: "Search accounts...",
|
||||
emptyMessage: "No accounts found.",
|
||||
}}
|
||||
>
|
||||
<MultiSelectItem value="aws-prod">Production AWS</MultiSelectItem>
|
||||
<MultiSelectItem value="azure-dev">Development Azure</MultiSelectItem>
|
||||
</MultiSelectContent>
|
||||
</MultiSelect>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.type(screen.getByPlaceholderText("Search accounts..."), "aws");
|
||||
|
||||
expect(
|
||||
within(screen.getByRole("dialog")).getByText("Production AWS"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByRole("dialog")).queryByText("Development Azure"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses a normalized dropdown width instead of growing with the longest item", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<MultiSelect values={[]} onValuesChange={() => {}}>
|
||||
<MultiSelectTrigger>
|
||||
<MultiSelectValue placeholder="Select accounts" />
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent search={false}>
|
||||
<MultiSelectItem value="very-long-item">
|
||||
This is a very long option label that should not expand the dropdown
|
||||
indefinitely
|
||||
</MultiSelectItem>
|
||||
</MultiSelectContent>
|
||||
</MultiSelect>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
|
||||
expect(screen.getByRole("dialog")).toHaveClass(
|
||||
"w-[min(var(--radix-popover-trigger-width),calc(100vw-2rem))]",
|
||||
);
|
||||
expect(screen.getByRole("dialog")).toHaveClass("max-w-[24rem]");
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,13 @@ import {
|
||||
} from "@/components/shadcn/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface MultiSelectSearchConfig {
|
||||
placeholder?: string;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
export type MultiSelectSearchProp = boolean | MultiSelectSearchConfig;
|
||||
|
||||
type MultiSelectContextType = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
@@ -263,18 +270,20 @@ export function MultiSelectContent({
|
||||
width = "default",
|
||||
...props
|
||||
}: {
|
||||
search?: boolean | { placeholder?: string; emptyMessage?: string };
|
||||
search?: MultiSelectSearchProp;
|
||||
children: ReactNode;
|
||||
width?: "default" | "wide";
|
||||
} & Omit<ComponentPropsWithoutRef<typeof Command>, "children">) {
|
||||
const canSearch = typeof search === "object" ? true : search;
|
||||
|
||||
const widthClasses =
|
||||
width === "wide" ? "w-auto min-w-[400px] max-w-[600px]" : "w-auto";
|
||||
width === "wide"
|
||||
? "w-[min(max(var(--radix-popover-trigger-width),24rem),calc(100vw-2rem))] max-w-[32rem]"
|
||||
: "w-[min(var(--radix-popover-trigger-width),calc(100vw-2rem))] max-w-[24rem]";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: "none" }}>
|
||||
<div className="hidden" aria-hidden="true">
|
||||
<Command>
|
||||
<CommandList>{children}</CommandList>
|
||||
</Command>
|
||||
@@ -298,15 +307,13 @@ export function MultiSelectContent({
|
||||
) : (
|
||||
<button className="sr-only" />
|
||||
)}
|
||||
<CommandList className="minimal-scrollbar max-h-[300px] overflow-x-hidden overflow-y-auto">
|
||||
<div className="flex flex-col gap-1 p-3">
|
||||
{canSearch && (
|
||||
<CommandEmpty className="text-bg-button-secondary py-6 text-center text-sm">
|
||||
{typeof search === "object" ? search.emptyMessage : undefined}
|
||||
</CommandEmpty>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
<CommandList className="minimal-scrollbar max-h-[300px] overflow-x-hidden overflow-y-auto p-3">
|
||||
{canSearch && (
|
||||
<CommandEmpty className="text-bg-button-secondary py-6 text-center text-sm">
|
||||
{typeof search === "object" ? search.emptyMessage : undefined}
|
||||
</CommandEmpty>
|
||||
)}
|
||||
{children}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
@@ -318,11 +325,13 @@ export function MultiSelectItem({
|
||||
value,
|
||||
children,
|
||||
badgeLabel,
|
||||
keywords,
|
||||
onSelect,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
badgeLabel?: ReactNode;
|
||||
keywords?: string[];
|
||||
value: string;
|
||||
} & Omit<ComponentPropsWithoutRef<typeof CommandItem>, "value">) {
|
||||
const { toggleValue, selectedValues, onItemAdded } = useMultiSelectContext();
|
||||
@@ -336,9 +345,10 @@ export function MultiSelectItem({
|
||||
<CommandItem
|
||||
{...props}
|
||||
value={value}
|
||||
keywords={keywords}
|
||||
data-slot="multiselect-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary flex w-full cursor-pointer items-center justify-between gap-3 rounded-lg px-4 py-3 text-sm outline-hidden select-none hover:bg-slate-200 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 dark:hover:bg-slate-700/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary flex w-full cursor-pointer items-center justify-between gap-3 overflow-hidden rounded-lg px-4 py-3 text-sm outline-hidden select-none hover:bg-slate-200 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 dark:hover:bg-slate-700/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
|
||||
isSelected && "bg-slate-100 dark:bg-slate-800/50",
|
||||
className,
|
||||
)}
|
||||
@@ -347,7 +357,9 @@ export function MultiSelectItem({
|
||||
onSelect?.(value);
|
||||
}}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">{children}</span>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
||||
{children}
|
||||
</span>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"text-bg-button-secondary size-5 shrink-0",
|
||||
@@ -368,6 +380,12 @@ export function MultiSelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof CommandSeparator>) {
|
||||
const { selectedValues } = useMultiSelectContext();
|
||||
|
||||
if (selectedValues.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandSeparator
|
||||
data-slot="multiselect-separator"
|
||||
@@ -392,8 +410,11 @@ export function MultiSelectSelectAll({
|
||||
|
||||
const hasSelections = selectedValues.size > 0;
|
||||
|
||||
if (!hasSelections) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClearAll = () => {
|
||||
// Clear all selections
|
||||
onValuesChange?.([]);
|
||||
};
|
||||
|
||||
|
||||
447
ui/components/shared/query-code-editor.tsx
Normal file
447
ui/components/shared/query-code-editor.tsx
Normal file
@@ -0,0 +1,447 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
HighlightStyle,
|
||||
StreamLanguage,
|
||||
syntaxHighlighting,
|
||||
} from "@codemirror/language";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { tags } from "@lezer/highlight";
|
||||
import CodeMirror, {
|
||||
EditorView,
|
||||
highlightActiveLineGutter,
|
||||
lineNumbers,
|
||||
placeholder as codeEditorPlaceholder,
|
||||
} from "@uiw/react-codemirror";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { type HTMLAttributes, useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/shadcn";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const QUERY_EDITOR_LANGUAGE = {
|
||||
OPEN_CYPHER: "openCypher",
|
||||
PLAIN_TEXT: "plainText",
|
||||
} as const;
|
||||
|
||||
type QueryEditorLanguage =
|
||||
(typeof QUERY_EDITOR_LANGUAGE)[keyof typeof QUERY_EDITOR_LANGUAGE];
|
||||
|
||||
const OPEN_CYPHER_KEYWORDS = new Set([
|
||||
"all",
|
||||
"and",
|
||||
"as",
|
||||
"asc",
|
||||
"ascending",
|
||||
"by",
|
||||
"call",
|
||||
"case",
|
||||
"contains",
|
||||
"create",
|
||||
"delete",
|
||||
"desc",
|
||||
"descending",
|
||||
"detach",
|
||||
"distinct",
|
||||
"else",
|
||||
"end",
|
||||
"exists",
|
||||
"false",
|
||||
"in",
|
||||
"is",
|
||||
"limit",
|
||||
"match",
|
||||
"merge",
|
||||
"not",
|
||||
"null",
|
||||
"optional",
|
||||
"or",
|
||||
"order",
|
||||
"remove",
|
||||
"return",
|
||||
"set",
|
||||
"skip",
|
||||
"then",
|
||||
"true",
|
||||
"unwind",
|
||||
"where",
|
||||
"with",
|
||||
"xor",
|
||||
"yield",
|
||||
]);
|
||||
|
||||
const OPEN_CYPHER_FUNCTIONS = new Set([
|
||||
"collect",
|
||||
"coalesce",
|
||||
"count",
|
||||
"exists",
|
||||
"head",
|
||||
"id",
|
||||
"keys",
|
||||
"labels",
|
||||
"last",
|
||||
"length",
|
||||
"nodes",
|
||||
"properties",
|
||||
"range",
|
||||
"reduce",
|
||||
"relationships",
|
||||
"size",
|
||||
"startnode",
|
||||
"sum",
|
||||
"tail",
|
||||
"timestamp",
|
||||
"tolower",
|
||||
"toupper",
|
||||
"trim",
|
||||
"type",
|
||||
]);
|
||||
|
||||
interface OpenCypherParserState {
|
||||
inBlockComment: boolean;
|
||||
inString: "'" | '"' | null;
|
||||
}
|
||||
|
||||
const openCypherLanguage = StreamLanguage.define<OpenCypherParserState>({
|
||||
startState() {
|
||||
return {
|
||||
inBlockComment: false,
|
||||
inString: null,
|
||||
};
|
||||
},
|
||||
token(stream, state) {
|
||||
if (state.inBlockComment) {
|
||||
while (!stream.eol()) {
|
||||
if (stream.match("*/")) {
|
||||
state.inBlockComment = false;
|
||||
break;
|
||||
}
|
||||
stream.next();
|
||||
}
|
||||
|
||||
return "comment";
|
||||
}
|
||||
|
||||
if (state.inString) {
|
||||
let escaped = false;
|
||||
|
||||
while (!stream.eol()) {
|
||||
const next = stream.next();
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (next === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (next === state.inString) {
|
||||
state.inString = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (stream.eol()) {
|
||||
state.inString = null;
|
||||
}
|
||||
|
||||
return "string";
|
||||
}
|
||||
|
||||
if (stream.eatSpace()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (stream.match("//")) {
|
||||
stream.skipToEnd();
|
||||
return "comment";
|
||||
}
|
||||
|
||||
if (stream.match("/*")) {
|
||||
state.inBlockComment = true;
|
||||
return "comment";
|
||||
}
|
||||
|
||||
const quote = stream.peek();
|
||||
if (quote === "'" || quote === '"') {
|
||||
state.inString = quote;
|
||||
stream.next();
|
||||
return "string";
|
||||
}
|
||||
|
||||
if (stream.match(/\$[A-Za-z_][\w]*/)) {
|
||||
return "variableName";
|
||||
}
|
||||
|
||||
if (stream.match(/:[A-Za-z_][\w]*/)) {
|
||||
return "typeName";
|
||||
}
|
||||
|
||||
if (stream.match(/[()[\]{},.;]/)) {
|
||||
return "punctuation";
|
||||
}
|
||||
|
||||
if (stream.match(/[<>!=~|&+\-/*%^]+/)) {
|
||||
return "operator";
|
||||
}
|
||||
|
||||
if (stream.match(/\d+(?:\.\d+)?/)) {
|
||||
return "number";
|
||||
}
|
||||
|
||||
if (stream.match(/[A-Za-z_][\w]*/)) {
|
||||
const currentValue = stream.current();
|
||||
const normalizedValue = currentValue.toLowerCase();
|
||||
|
||||
if (OPEN_CYPHER_KEYWORDS.has(normalizedValue)) {
|
||||
return "keyword";
|
||||
}
|
||||
|
||||
if (
|
||||
OPEN_CYPHER_FUNCTIONS.has(normalizedValue) &&
|
||||
stream.match(/\s*(?=\()/, false)
|
||||
) {
|
||||
return "function";
|
||||
}
|
||||
|
||||
return "variableName";
|
||||
}
|
||||
|
||||
stream.next();
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
const lightHighlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: "#0550ae", fontWeight: "600" },
|
||||
{ tag: tags.string, color: "#0a3069" },
|
||||
{ tag: tags.number, color: "#8250df" },
|
||||
{ tag: [tags.typeName, tags.className], color: "#953800" },
|
||||
{ tag: [tags.variableName, tags.propertyName], color: "#24292f" },
|
||||
{ tag: tags.function(tags.variableName), color: "#8250df" },
|
||||
{ tag: tags.operator, color: "#57606a" },
|
||||
{ tag: tags.comment, color: "#6e7781", fontStyle: "italic" },
|
||||
{ tag: tags.punctuation, color: "#57606a" },
|
||||
]);
|
||||
|
||||
const darkHighlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: "#79c0ff", fontWeight: "600" },
|
||||
{ tag: tags.string, color: "#a5d6ff" },
|
||||
{ tag: tags.number, color: "#d2a8ff" },
|
||||
{ tag: [tags.typeName, tags.className], color: "#ffa657" },
|
||||
{ tag: [tags.variableName, tags.propertyName], color: "#e6edf3" },
|
||||
{ tag: tags.function(tags.variableName), color: "#d2a8ff" },
|
||||
{ tag: tags.operator, color: "#8b949e" },
|
||||
{ tag: tags.comment, color: "#8b949e", fontStyle: "italic" },
|
||||
{ tag: tags.punctuation, color: "#8b949e" },
|
||||
]);
|
||||
|
||||
const MONO_FONT =
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace';
|
||||
|
||||
const LIGHT_SELECTION_BG = "rgba(9, 105, 218, 0.18)";
|
||||
const DARK_SELECTION_BG = "rgba(121, 192, 255, 0.18)";
|
||||
|
||||
function createEditorTheme({
|
||||
isDarkMode,
|
||||
minHeight,
|
||||
}: {
|
||||
isDarkMode: boolean;
|
||||
minHeight: number;
|
||||
}) {
|
||||
return EditorView.theme(
|
||||
{
|
||||
"&": {
|
||||
backgroundColor: "transparent",
|
||||
color: "var(--text-neutral-primary)",
|
||||
fontFamily: MONO_FONT,
|
||||
fontSize: "12px",
|
||||
},
|
||||
"&.cm-focused": {
|
||||
outline: "none",
|
||||
},
|
||||
".cm-scroller": {
|
||||
minHeight: `${minHeight}px`,
|
||||
overflow: "auto",
|
||||
fontFamily: MONO_FONT,
|
||||
lineHeight: "1.5rem",
|
||||
},
|
||||
".cm-content": {
|
||||
padding: "16px",
|
||||
caretColor: "var(--text-neutral-primary)",
|
||||
},
|
||||
".cm-line": {
|
||||
padding: "0 0 0 8px",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "var(--bg-neutral-secondary)",
|
||||
color: "var(--text-neutral-tertiary)",
|
||||
borderRight: "1px solid var(--border-neutral-secondary)",
|
||||
minWidth: "44px",
|
||||
},
|
||||
".cm-lineNumbers .cm-gutterElement": {
|
||||
padding: "0 10px 0 12px",
|
||||
},
|
||||
".cm-activeLineGutter": {
|
||||
backgroundColor: "var(--bg-neutral-secondary)",
|
||||
color: "var(--text-neutral-secondary)",
|
||||
},
|
||||
".cm-activeLine": {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
".cm-cursor, .cm-dropCursor": {
|
||||
borderLeftColor: "var(--text-neutral-primary)",
|
||||
},
|
||||
".cm-placeholder": {
|
||||
color: "var(--text-neutral-tertiary)",
|
||||
},
|
||||
".cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection":
|
||||
{
|
||||
backgroundColor: isDarkMode ? DARK_SELECTION_BG : LIGHT_SELECTION_BG,
|
||||
},
|
||||
},
|
||||
{ dark: isDarkMode },
|
||||
);
|
||||
}
|
||||
|
||||
interface QueryCodeEditorProps
|
||||
extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
|
||||
ariaLabel: string;
|
||||
language?: QueryEditorLanguage;
|
||||
value: string;
|
||||
copyValue?: string;
|
||||
placeholder?: string;
|
||||
invalid?: boolean;
|
||||
requirementBadge?: string;
|
||||
editable?: boolean;
|
||||
minHeight?: number;
|
||||
showCopyButton?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
export const QueryCodeEditor = ({
|
||||
id,
|
||||
className,
|
||||
ariaLabel,
|
||||
language = QUERY_EDITOR_LANGUAGE.OPEN_CYPHER,
|
||||
value,
|
||||
copyValue,
|
||||
placeholder,
|
||||
invalid = false,
|
||||
requirementBadge,
|
||||
editable = true,
|
||||
minHeight = 320,
|
||||
showCopyButton = false,
|
||||
onChange,
|
||||
onBlur,
|
||||
...props
|
||||
}: QueryCodeEditorProps) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const isDarkMode = resolvedTheme === "dark";
|
||||
const editorTheme = createEditorTheme({ isDarkMode, minHeight });
|
||||
const editorHighlightStyle = isDarkMode
|
||||
? darkHighlightStyle
|
||||
: lightHighlightStyle;
|
||||
|
||||
const extensions = [
|
||||
lineNumbers(),
|
||||
highlightActiveLineGutter(),
|
||||
EditorView.lineWrapping,
|
||||
codeEditorPlaceholder(placeholder ?? ""),
|
||||
EditorView.contentAttributes.of({
|
||||
id: id ?? "",
|
||||
"aria-label": ariaLabel,
|
||||
"aria-invalid": invalid ? "true" : "false",
|
||||
"aria-readonly": editable ? "false" : "true",
|
||||
}),
|
||||
EditorView.editorAttributes.of({
|
||||
class: cn("minimal-scrollbar", !editable && "cursor-text"),
|
||||
}),
|
||||
EditorView.domEventHandlers({
|
||||
blur: () => {
|
||||
onBlur?.();
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
if (!editable) {
|
||||
extensions.push(EditorState.readOnly.of(true));
|
||||
}
|
||||
|
||||
if (language === QUERY_EDITOR_LANGUAGE.OPEN_CYPHER) {
|
||||
extensions.push(
|
||||
openCypherLanguage,
|
||||
syntaxHighlighting(editorHighlightStyle),
|
||||
);
|
||||
}
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(copyValue ?? value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="query-code-editor"
|
||||
data-language={language}
|
||||
className={cn(
|
||||
"border-border-neutral-secondary bg-bg-neutral-primary overflow-hidden rounded-xl border",
|
||||
invalid && "border-border-error-primary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex items-center justify-between border-b px-4 py-2">
|
||||
<span className="text-text-neutral-secondary text-xs font-medium">
|
||||
{ariaLabel}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{requirementBadge ? (
|
||||
<Badge
|
||||
variant="tag"
|
||||
className="text-text-neutral-secondary border-border-neutral-secondary bg-bg-neutral-primary px-2 py-0 text-[11px]"
|
||||
>
|
||||
{requirementBadge}
|
||||
</Badge>
|
||||
) : null}
|
||||
{showCopyButton ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Copy ${ariaLabel}`}
|
||||
onClick={() => void handleCopy()}
|
||||
className="text-text-neutral-secondary hover:text-text-neutral-primary shrink-0 cursor-pointer transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CodeMirror
|
||||
value={value}
|
||||
theme={editorTheme}
|
||||
basicSetup={{
|
||||
foldGutter: false,
|
||||
highlightActiveLine: false,
|
||||
highlightActiveLineGutter: false,
|
||||
searchKeymap: false,
|
||||
}}
|
||||
editable={editable}
|
||||
onChange={onChange}
|
||||
extensions={extensions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
export * from "./date-with-time";
|
||||
export * from "./entity-info";
|
||||
export * from "./get-provider-logo";
|
||||
export * from "./info-field";
|
||||
export * from "./scan-status";
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { Tooltip } from "@heroui/tooltip";
|
||||
import clsx from "clsx";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
|
||||
interface InfoFieldProps {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
variant?: "default" | "simple" | "transparent";
|
||||
className?: string;
|
||||
tooltipContent?: string;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
<Tooltip
|
||||
className="text-xs"
|
||||
content="Download a ZIP file that includes the JSON (OCSF), CSV, and HTML scan reports, along with the compliance report."
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<InfoIcon className="text-primary mb-1" size={12} />
|
||||
</div>
|
||||
</Tooltip>;
|
||||
|
||||
export const InfoField = ({
|
||||
label,
|
||||
children,
|
||||
variant = "default",
|
||||
tooltipContent,
|
||||
className,
|
||||
inline = false,
|
||||
}: InfoFieldProps) => {
|
||||
if (inline) {
|
||||
return (
|
||||
<div className={clsx("flex items-center gap-2", className)}>
|
||||
<span className="text-text-neutral-tertiary text-xs font-bold">
|
||||
<span className="flex items-center gap-1">
|
||||
{label}:
|
||||
{tooltipContent && (
|
||||
<Tooltip className="text-xs" content={tooltipContent}>
|
||||
<div className="flex cursor-pointer items-center gap-2">
|
||||
<InfoIcon className="text-bg-data-info mb-1" size={12} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<div className="text-text-neutral-primary text-sm">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx("flex flex-col gap-1", className)}>
|
||||
<span className="text-text-neutral-tertiary text-xs font-bold">
|
||||
<span className="flex items-center gap-1">
|
||||
{label}
|
||||
{tooltipContent && (
|
||||
<Tooltip className="text-xs" content={tooltipContent}>
|
||||
<div className="flex cursor-pointer items-center gap-2">
|
||||
<InfoIcon className="text-bg-data-info mb-1" size={12} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{variant === "simple" ? (
|
||||
<div className="text-text-neutral-primary text-sm break-all">
|
||||
{children}
|
||||
</div>
|
||||
) : variant === "transparent" ? (
|
||||
<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">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -166,7 +166,7 @@ describe("DataTableFilterCustom — batch vs instant mode", () => {
|
||||
render(<DataTableFilterCustom filters={[severityFilter]} />);
|
||||
|
||||
// Then — renders without crashing
|
||||
expect(screen.getByText("Severity")).toBeInTheDocument();
|
||||
expect(screen.getByText("All Severity")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -185,7 +185,9 @@ export const DataTableFilterCustom = ({
|
||||
onValuesChange={(values) => pushDropdownFilter(filter, values)}
|
||||
>
|
||||
<MultiSelectTrigger size="default">
|
||||
<MultiSelectValue placeholder={filter.labelCheckboxGroup} />
|
||||
<MultiSelectValue
|
||||
placeholder={`All ${filter.labelCheckboxGroup}`}
|
||||
/>
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent search={false}>
|
||||
<MultiSelectSelectAll>Select All</MultiSelectSelectAll>
|
||||
|
||||
@@ -108,6 +108,8 @@ interface DataTableProviderProps<TData, TValue> {
|
||||
renderAfterRow?: (row: Row<TData>) => ReactNode;
|
||||
/** Badge shown inside the search input (e.g., active drill-down group) */
|
||||
searchBadge?: { label: string; onDismiss: () => void };
|
||||
/** Optional click handler for top-level rows. */
|
||||
onRowClick?: (row: Row<TData>) => void;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
@@ -137,6 +139,7 @@ export function DataTable<TData, TValue>({
|
||||
searchPlaceholder,
|
||||
renderAfterRow,
|
||||
searchBadge,
|
||||
onRowClick,
|
||||
}: DataTableProviderProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
@@ -213,6 +216,18 @@ export function DataTable<TData, TValue>({
|
||||
|
||||
const rows = table.getRowModel().rows;
|
||||
|
||||
const handleRowClick = (row: Row<TData>, target: HTMLElement | null) => {
|
||||
if (!onRowClick) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (target?.closest("a, button, input, [role=menuitem]")) {
|
||||
return;
|
||||
}
|
||||
|
||||
onRowClick(row);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -282,7 +297,13 @@ export function DataTable<TData, TValue>({
|
||||
/>
|
||||
) : (
|
||||
<Fragment key={row.id}>
|
||||
<TableRow data-state={row.getIsSelected() && "selected"}>
|
||||
<TableRow
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className={cn(onRowClick && "cursor-pointer")}
|
||||
onClick={(event) =>
|
||||
handleRowClick(row, event.target as HTMLElement)
|
||||
}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { Badge, Button, Card } from "@/components/shadcn";
|
||||
import { InfoField } from "@/components/shadcn/info-field/info-field";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { DateWithTime, InfoField } from "@/components/ui/entities";
|
||||
import { DateWithTime } from "@/components/ui/entities";
|
||||
import { EditTenantForm } from "@/components/users/forms";
|
||||
import { DeleteTenantForm } from "@/components/users/forms/delete-tenant-form";
|
||||
import { SwitchTenantForm } from "@/components/users/forms/switch-tenant-form";
|
||||
|
||||
@@ -4,8 +4,9 @@ import { Divider } from "@heroui/divider";
|
||||
|
||||
import { ProwlerShort } from "@/components/icons";
|
||||
import { Card, CardContent } from "@/components/shadcn";
|
||||
import { InfoField } from "@/components/shadcn/info-field/info-field";
|
||||
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
|
||||
import { DateWithTime, InfoField } from "@/components/ui/entities";
|
||||
import { DateWithTime } from "@/components/ui/entities";
|
||||
import { UserDataWithRoles } from "@/types/users";
|
||||
|
||||
const TenantIdCopy = ({ id }: { id: string }) => {
|
||||
|
||||
51
ui/lib/helper-filters.test.ts
Normal file
51
ui/lib/helper-filters.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
hasDateFilter,
|
||||
hasDateOrScanFilter,
|
||||
hasHistoricalFindingFilter,
|
||||
} from "./helper-filters";
|
||||
|
||||
describe("hasDateOrScanFilter", () => {
|
||||
it("returns true for scan filters", () => {
|
||||
expect(hasDateOrScanFilter({ "filter[scan__in]": "scan-1" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for inserted_at filters", () => {
|
||||
expect(
|
||||
hasDateOrScanFilter({ "filter[inserted_at__gte]": "2026-04-01" }),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasDateFilter", () => {
|
||||
it("returns true for inserted_at filters", () => {
|
||||
expect(hasDateFilter({ "filter[inserted_at__lte]": "2026-04-07" })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false for scan filters only", () => {
|
||||
expect(hasDateFilter({ "filter[scan__in]": "scan-1" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasHistoricalFindingFilter", () => {
|
||||
it("returns true for inserted_at filters", () => {
|
||||
expect(
|
||||
hasHistoricalFindingFilter({ "filter[inserted_at__gte]": "2026-04-01" }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for scan filters", () => {
|
||||
expect(hasHistoricalFindingFilter({ "filter[scan__in]": "scan-1" })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when neither date nor scan filters are active", () => {
|
||||
expect(
|
||||
hasHistoricalFindingFilter({ "filter[provider_type__in]": "aws" }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,22 @@ export const hasDateOrScanFilter = (searchParams: Record<string, unknown>) =>
|
||||
(key) => key.includes("inserted_at") || key.includes("scan__in"),
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns true when finding views must use historical endpoints.
|
||||
* Scan filters are resolved to inserted_at server-side, but client drill-downs
|
||||
* still need to treat raw scan params as historical to stay aligned.
|
||||
*/
|
||||
export const hasHistoricalFindingFilter = (
|
||||
searchParams: Record<string, unknown>,
|
||||
) => hasDateOrScanFilter(searchParams);
|
||||
|
||||
/**
|
||||
* Returns true when inserted_at filters are active.
|
||||
* Used by resources drill-down endpoints that support date scoping but not scan filters.
|
||||
*/
|
||||
export const hasDateFilter = (searchParams: Record<string, unknown>) =>
|
||||
Object.keys(searchParams).some((key) => key.includes("inserted_at"));
|
||||
|
||||
/**
|
||||
* Encodes sort strings by removing leading "+" symbols.
|
||||
*/
|
||||
|
||||
@@ -73,5 +73,7 @@ export type FilterParam =
|
||||
| "filter[category__in]"
|
||||
| "filter[resource_groups__in]"
|
||||
| "filter[scan__in]"
|
||||
| "filter[scan_id]"
|
||||
| "filter[scan_id__in]"
|
||||
| "filter[inserted_at]"
|
||||
| "filter[muted]";
|
||||
|
||||
Reference in New Issue
Block a user