Compare commits

...

3 Commits

Author SHA1 Message Date
César Arroba
ed6af6e003 fix(ci): remove broken resolved_reference step from setup-python-poetry
The step "Update SDK resolved_reference to latest commit (prowler repo on
push)" ran `grep "resolved_reference" poetry.lock` against the main prowler
repo, but the root `poetry.lock` has no `resolved_reference` entries (the
repo does not self-reference via git+https). As a result, grep exits 1 and
fails the step on every push to master.

This broke `sdk-container-build-push.yml` on every push to master after
PR #10681 migrated it to this composite action.

The sibling step that updates downstream repositories remains untouched.
2026-04-14 18:41:31 +02:00
Alejandro Bailo
507b0882d5 fix(ui): fix findings group resource filters and mute modal migration (#10662) 2026-04-14 18:01:45 +02:00
Alejandro Bailo
89d72cf8fd feat(ui): new resources side drawer with redesigned detail panel (#10673) 2026-04-14 17:20:19 +02:00
51 changed files with 2234 additions and 1108 deletions

View File

@@ -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

View File

@@ -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)
---

View File

@@ -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");
});
});

View File

@@ -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));

View File

@@ -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);
}

View File

@@ -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"));
});
});

View File

@@ -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>

View File

@@ -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"),
);
});
});

View File

@@ -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>

View File

@@ -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";

View File

@@ -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";

View File

@@ -153,6 +153,8 @@ const SSRDataTable = async ({
key={groupKey}
data={groups}
metadata={findingGroupsData?.meta}
resolvedFilters={filters}
hasHistoricalData={hasDateOrScan}
/>
</>
);

View 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");
});
});

View File

@@ -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}
/>
</>
);

View File

@@ -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",
};

View File

@@ -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", () => {

View File

@@ -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(

View 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);
});
});
});

View File

@@ -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}

View File

@@ -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 =

View File

@@ -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",

View File

@@ -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,

View File

@@ -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}

View File

@@ -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>
);
};

View File

@@ -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";

View File

@@ -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,

View File

@@ -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();
});
});
// ---------------------------------------------------------------------------

View File

@@ -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>
)}

View File

@@ -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([]);
});
});

View File

@@ -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);
};

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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,
},
];
];
}

View File

@@ -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>
);
};

View File

@@ -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);
}}
/>
)}
</>
);
}

View File

@@ -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";

View File

@@ -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>
);

View 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]");
});
});

View File

@@ -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?.([]);
};

View 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>
);
};

View File

@@ -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";

View File

@@ -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>
);
};

View File

@@ -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();
});
});

View File

@@ -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>

View File

@@ -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(

View File

@@ -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";

View File

@@ -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 }) => {

View 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);
});
});

View File

@@ -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.
*/

View File

@@ -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]";