mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d09ab9f220 | ||
|
|
bec79820f6 | ||
|
|
0ba8767a05 | ||
|
|
9a2b32268d | ||
|
|
9119598a67 | ||
|
|
5381ceb1f9 | ||
|
|
10820274eb | ||
|
|
ebba304a83 | ||
|
|
2411c1851c | ||
|
|
c3db09f60e | ||
|
|
c7b288101c | ||
|
|
611e93e985 | ||
|
|
aa5b48e03d |
+6
-1
@@ -14,14 +14,19 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
- Allows tenant owners to expel users from their organizations [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787)
|
||||
- Shared filter dropdowns now support local option search and auto-scroll to the first visible match across table and provider filters [(#10859)](https://github.com/prowler-cloud/prowler/pull/10859)
|
||||
- Backward-compatibility middleware redirect from `/sign-up?invitation_token=…` to `/invitation/accept?invitation_token=…`; new invitation emails use `/invitation/accept` directly [(#10797)](https://github.com/prowler-cloud/prowler/pull/10797)
|
||||
- Mutelist improvements: table now supports name/reason search and visual count badges for finding targets [(#10846)](https://github.com/prowler-cloud/prowler/pull/10846)
|
||||
- Resources now use batch-applied filters, render metadata JSON with syntax highlighting, and more [(#10861)](https://github.com/prowler-cloud/prowler/pull/10861)
|
||||
- Added knip for dead code detection with `lint:knip` and `lint:knip:fix` scripts [(#10654)](https://github.com/prowler-cloud/prowler/pull/10654)
|
||||
- Table pagination controls now keep their arrows visible on hover in light theme, and more UI improvements [(#10862)](https://github.com/prowler-cloud/prowler/pull/10862)
|
||||
|
||||
---
|
||||
|
||||
## [1.24.4] (Prowler UNRELEASED)
|
||||
## [1.24.4] (Prowler 5.24.4)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Provider wizard no longer advances to the Launch Scan step when rotating credentials [(#10851)](https://github.com/prowler-cloud/prowler/pull/10851)
|
||||
- Attack Paths scan selector now lists scans from every provider with working pagination, instead of capping the list at the first ten [(#10864)](https://github.com/prowler-cloud/prowler/pull/10864)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("findings group table", () => {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const filePath = path.join(currentDir, "findings-group-table.tsx");
|
||||
const source = readFileSync(filePath, "utf8");
|
||||
|
||||
it("refreshes grouped findings locally after mute instead of forcing a router refresh", () => {
|
||||
expect(source).toContain("refreshFindingGroups");
|
||||
expect(source).toContain("adaptFindingGroupsResponse");
|
||||
expect(source).toContain("getLatestFindingGroups");
|
||||
expect(source).not.toContain("router.refresh()");
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { Row, RowSelectionState } from "@tanstack/react-table";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
adaptFindingGroupsResponse,
|
||||
getFindingGroups,
|
||||
getLatestFindingGroups,
|
||||
} from "@/actions/finding-groups";
|
||||
import { resolveFindingIdsByVisibleGroupResources } from "@/actions/findings/findings-by-resource";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { canDrillDownFindingGroup } from "@/lib/findings-groups";
|
||||
@@ -44,8 +49,12 @@ export function FindingsGroupTable({
|
||||
resolvedFilters,
|
||||
hasHistoricalData,
|
||||
}: FindingsGroupTableProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [tableData, setTableData] = useState<FindingGroupRow[]>(data ?? []);
|
||||
const [tableMetadata, setTableMetadata] = useState<MetaDataProps | undefined>(
|
||||
metadata,
|
||||
);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [expandedCheckId, setExpandedCheckId] = useState<string | null>(null);
|
||||
const [expandedGroup, setExpandedGroup] = useState<FindingGroupRow | null>(
|
||||
@@ -61,7 +70,7 @@ export function FindingsGroupTable({
|
||||
// State resets (selection, drill-down) are handled by the parent via
|
||||
// key={groupKey} — when data changes, the component remounts with fresh state.
|
||||
|
||||
const safeData = data ?? [];
|
||||
const safeData = tableData ?? [];
|
||||
const hasResourceSelection = resourceSelection.length > 0;
|
||||
const filters = resolvedFilters;
|
||||
|
||||
@@ -131,12 +140,49 @@ export function FindingsGroupTable({
|
||||
const resolveMuteIds = async (checkIds: string[]) =>
|
||||
resolveGroupMuteIds(checkIds);
|
||||
|
||||
const handleMuteComplete = () => {
|
||||
const refreshFindingGroups = async () => {
|
||||
setIsRefreshing(true);
|
||||
|
||||
const page = parseInt(searchParams.get("page") || "1", 10);
|
||||
const pageSize = parseInt(searchParams.get("pageSize") || "10", 10);
|
||||
const sort = searchParams.get("sort") || undefined;
|
||||
const fetchFindingGroups = hasHistoricalData
|
||||
? getFindingGroups
|
||||
: getLatestFindingGroups;
|
||||
|
||||
try {
|
||||
const findingGroupsData = await fetchFindingGroups({
|
||||
page,
|
||||
...(sort && { sort }),
|
||||
filters,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
const nextGroups = adaptFindingGroupsResponse(findingGroupsData);
|
||||
setTableData(nextGroups);
|
||||
setTableMetadata(findingGroupsData?.meta);
|
||||
|
||||
if (expandedCheckId) {
|
||||
const refreshedExpandedGroup =
|
||||
nextGroups.find((group) => group.checkId === expandedCheckId) ?? null;
|
||||
|
||||
if (refreshedExpandedGroup) {
|
||||
setExpandedGroup(refreshedExpandedGroup);
|
||||
} else {
|
||||
handleCollapse();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMuteComplete = async () => {
|
||||
clearSelection();
|
||||
setResourceSelection([]);
|
||||
inlineRef.current?.clearSelection();
|
||||
await refreshFindingGroups();
|
||||
inlineRef.current?.refresh();
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleDrillDown = (checkId: string, group: FindingGroupRow) => {
|
||||
@@ -203,12 +249,13 @@ export function FindingsGroupTable({
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={safeData}
|
||||
metadata={metadata}
|
||||
metadata={tableMetadata}
|
||||
enableRowSelection
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
getRowCanSelect={getRowCanSelect}
|
||||
showSearch
|
||||
isLoading={isRefreshing}
|
||||
searchPlaceholder={
|
||||
expandedCheckId ? "Search resources..." : "Search by name"
|
||||
}
|
||||
|
||||
@@ -13,4 +13,9 @@ describe("inline resource container", () => {
|
||||
expect(source).toContain("useFindingGroupResourceState");
|
||||
expect(source).not.toContain("useInfiniteResources");
|
||||
});
|
||||
|
||||
it("renders a resources heading above the expanded group rows", () => {
|
||||
expect(source).toContain("Resources");
|
||||
expect(source).toContain("text-xs font-medium");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -230,6 +230,11 @@ export function InlineResourceContainer({
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="px-6 pb-2">
|
||||
<p className="text-text-neutral-secondary text-xs font-medium">
|
||||
Resources
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
ref={combinedScrollRef}
|
||||
className="max-h-[440px] overflow-y-auto pl-6"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("notification indicator", () => {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const filePath = path.join(currentDir, "notification-indicator.tsx");
|
||||
const source = readFileSync(filePath, "utf8");
|
||||
|
||||
it("uses a popover for delta learn-more content so the link stays interactive", () => {
|
||||
expect(source).toContain("<Popover");
|
||||
expect(source).toContain("Learn more");
|
||||
expect(source).not.toContain("<Tooltip>");
|
||||
});
|
||||
});
|
||||
@@ -10,11 +10,6 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/shadcn/popover";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { DOCS_URLS } from "@/lib/external-urls";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FINDING_DELTA, type FindingDelta } from "@/types";
|
||||
@@ -67,12 +62,17 @@ function DeltaIndicator({
|
||||
}: {
|
||||
delta: typeof DeltaValues.NEW | typeof DeltaValues.CHANGED;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex w-2 shrink-0 cursor-pointer items-center justify-center"
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
className="flex w-2 shrink-0 cursor-pointer items-center justify-center bg-transparent p-0"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -82,9 +82,15 @@ function DeltaIndicator({
|
||||
: "bg-system-severity-low",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="border-border-neutral-tertiary bg-bg-neutral-tertiary w-auto rounded-lg px-2 py-1.5 shadow-lg"
|
||||
sideOffset={4}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span>
|
||||
{delta === DeltaValues.NEW
|
||||
@@ -107,8 +113,8 @@ function DeltaIndicator({
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
|
||||
import type { ProvidersTableRow } from "@/types/providers-table";
|
||||
|
||||
vi.mock("@/components/providers/add-provider-button", () => ({
|
||||
AddProviderButton: () => <button type="button">Add provider</button>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/muted-findings-config-button", () => ({
|
||||
MutedFindingsConfigButton: () => (
|
||||
<button type="button">Muted findings config</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/providers-filters", () => ({
|
||||
ProvidersFilters: () => <div data-testid="providers-filters">Filters</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/providers-accounts-table", () => ({
|
||||
ProvidersAccountsTable: () => <div data-testid="providers-table">Table</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/wizard", () => ({
|
||||
ProviderWizardModal: () => <div data-testid="provider-wizard-modal" />,
|
||||
}));
|
||||
|
||||
import { ProvidersAccountsView } from "./providers-accounts-view";
|
||||
|
||||
const filters: FilterOption[] = [];
|
||||
const providers: ProviderProps[] = [];
|
||||
const rows: ProvidersTableRow[] = [];
|
||||
const metadata: MetaDataProps = {
|
||||
pagination: { page: 1, pages: 1, count: 0, itemsPerPage: [10] },
|
||||
version: "latest",
|
||||
};
|
||||
|
||||
describe("ProvidersAccountsView", () => {
|
||||
it("keeps the same vertical spacing between filters and table as other views", () => {
|
||||
render(
|
||||
<ProvidersAccountsView
|
||||
isCloud={false}
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={providers}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("providers-filters").parentElement).toHaveClass(
|
||||
"flex",
|
||||
"flex-col",
|
||||
"gap-6",
|
||||
);
|
||||
expect(screen.getByTestId("providers-table")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -60,23 +60,25 @@ export function ProvidersAccountsView({
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProvidersFilters
|
||||
filters={filters}
|
||||
providers={providers}
|
||||
actions={
|
||||
<>
|
||||
<MutedFindingsConfigButton />
|
||||
<AddProviderButton onOpenWizard={() => openProviderWizard()} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<ProvidersAccountsTable
|
||||
isCloud={isCloud}
|
||||
metadata={metadata}
|
||||
rows={rows}
|
||||
onOpenProviderWizard={openProviderWizard}
|
||||
onOpenOrganizationWizard={openOrganizationWizard}
|
||||
/>
|
||||
<div className="flex flex-col gap-6">
|
||||
<ProvidersFilters
|
||||
filters={filters}
|
||||
providers={providers}
|
||||
actions={
|
||||
<>
|
||||
<MutedFindingsConfigButton />
|
||||
<AddProviderButton onOpenWizard={() => openProviderWizard()} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<ProvidersAccountsTable
|
||||
isCloud={isCloud}
|
||||
metadata={metadata}
|
||||
rows={rows}
|
||||
onOpenProviderWizard={openProviderWizard}
|
||||
onOpenOrganizationWizard={openOrganizationWizard}
|
||||
/>
|
||||
</div>
|
||||
<ProviderWizardModal
|
||||
open={isProviderWizardOpen}
|
||||
onOpenChange={handleWizardOpenChange}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DATA_TABLE_FILTER_MODE } from "@/types/filters";
|
||||
|
||||
const mockSetPending = vi.fn();
|
||||
const mockApplyAll = vi.fn();
|
||||
const mockDiscardAll = vi.fn();
|
||||
const mockClearAndApply = vi.fn();
|
||||
const mockGetFilterValue = vi.fn().mockReturnValue([]);
|
||||
|
||||
vi.mock("@/hooks/use-filter-batch", () => ({
|
||||
useFilterBatch: () => ({
|
||||
pendingFilters: {
|
||||
"filter[region__in]": ["eu-west-1"],
|
||||
},
|
||||
setPending: mockSetPending,
|
||||
applyAll: mockApplyAll,
|
||||
discardAll: mockDiscardAll,
|
||||
clearAndApply: mockClearAndApply,
|
||||
hasChanges: true,
|
||||
changeCount: 1,
|
||||
getFilterValue: mockGetFilterValue,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(prowler)/_overview/_components/provider-type-selector", () => ({
|
||||
ProviderTypeSelector: () => <div>Provider type selector</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(prowler)/_overview/_components/accounts-selector", () => ({
|
||||
AccountsSelector: () => <div>Accounts selector</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/filters/apply-filters-button", () => ({
|
||||
ApplyFiltersButton: ({
|
||||
hasChanges,
|
||||
changeCount,
|
||||
}: {
|
||||
hasChanges: boolean;
|
||||
changeCount: number;
|
||||
}) => (
|
||||
<div data-testid="apply-filters-button">
|
||||
{String(hasChanges)}:{changeCount}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/filters/clear-filters-button", () => ({
|
||||
ClearFiltersButton: ({ pendingCount }: { pendingCount?: number }) => (
|
||||
<div data-testid="clear-filters-button">{pendingCount ?? 0}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/filters/filter-summary-strip", () => ({
|
||||
FilterSummaryStrip: ({
|
||||
chips,
|
||||
}: {
|
||||
chips: Array<{ displayValue?: string; value: string }>;
|
||||
}) => (
|
||||
<div data-testid="filter-summary-strip">
|
||||
{chips.map((chip) => chip.displayValue ?? chip.value).join(",")}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn", () => ({
|
||||
Button: ({ children }: { children: React.ReactNode }) => (
|
||||
<button type="button">{children}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/expandable-section", () => ({
|
||||
ExpandableSection: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table", () => ({
|
||||
DataTableFilterCustom: ({ mode }: { mode?: string }) => (
|
||||
<div data-testid="data-table-filter-custom">{mode}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { ResourcesFilters } from "./resources-filters";
|
||||
|
||||
describe("ResourcesFilters", () => {
|
||||
it("uses batch mode controls and renders pending summary chips", () => {
|
||||
render(
|
||||
<ResourcesFilters
|
||||
providers={[]}
|
||||
uniqueRegions={["eu-west-1"]}
|
||||
uniqueServices={["ec2"]}
|
||||
uniqueResourceTypes={["aws_instance"]}
|
||||
uniqueGroups={["engineering_team"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("data-table-filter-custom")).toHaveTextContent(
|
||||
DATA_TABLE_FILTER_MODE.BATCH,
|
||||
);
|
||||
expect(screen.getByTestId("apply-filters-button")).toHaveTextContent(
|
||||
"true:1",
|
||||
);
|
||||
expect(screen.getByTestId("clear-filters-button")).toHaveTextContent("1");
|
||||
expect(screen.getByTestId("filter-summary-strip")).toHaveTextContent(
|
||||
"eu-west-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -5,13 +5,25 @@ import { useState } from "react";
|
||||
|
||||
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
|
||||
import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector";
|
||||
import { ApplyFiltersButton } from "@/components/filters/apply-filters-button";
|
||||
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
|
||||
import {
|
||||
FilterChip,
|
||||
FilterSummaryStrip,
|
||||
} from "@/components/filters/filter-summary-strip";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { ExpandableSection } from "@/components/ui/expandable-section";
|
||||
import { DataTableFilterCustom } from "@/components/ui/table";
|
||||
import { useFilterBatch } from "@/hooks/use-filter-batch";
|
||||
import { getGroupLabel } from "@/lib/categories";
|
||||
import { DATA_TABLE_FILTER_MODE } from "@/types/filters";
|
||||
import { ProviderProps } from "@/types/providers";
|
||||
|
||||
import {
|
||||
buildResourcesFilterChips,
|
||||
getResourcesFilterDisplayValue,
|
||||
} from "./resources-filters.utils";
|
||||
|
||||
interface ResourcesFiltersProps {
|
||||
providers: ProviderProps[];
|
||||
uniqueRegions: string[];
|
||||
@@ -28,6 +40,16 @@ export const ResourcesFilters = ({
|
||||
uniqueGroups,
|
||||
}: ResourcesFiltersProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const {
|
||||
pendingFilters,
|
||||
setPending,
|
||||
applyAll,
|
||||
discardAll,
|
||||
clearAndApply,
|
||||
hasChanges,
|
||||
changeCount,
|
||||
getFilterValue,
|
||||
} = useFilterBatch();
|
||||
|
||||
// Custom filters for the expandable section
|
||||
const customFilters = [
|
||||
@@ -59,16 +81,35 @@ export const ResourcesFilters = ({
|
||||
];
|
||||
|
||||
const hasCustomFilters = customFilters.length > 0;
|
||||
const filterChips: FilterChip[] = buildResourcesFilterChips(
|
||||
pendingFilters,
|
||||
providers,
|
||||
);
|
||||
|
||||
const handleChipRemove = (filterKey: string, value: string) => {
|
||||
const currentValues = pendingFilters[filterKey] ?? [];
|
||||
const nextValues = currentValues.filter((item) => item !== value);
|
||||
setPending(filterKey, nextValues);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* First row: Provider selectors + More Filters button + Clear Filters */}
|
||||
{/* First row: Provider selectors + More Filters button + Apply/Clear */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
|
||||
<ProviderTypeSelector providers={providers} />
|
||||
<ProviderTypeSelector
|
||||
providers={providers}
|
||||
onBatchChange={setPending}
|
||||
selectedValues={getFilterValue("filter[provider_type__in]")}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
|
||||
<AccountsSelector providers={providers} />
|
||||
<AccountsSelector
|
||||
providers={providers}
|
||||
onBatchChange={setPending}
|
||||
selectedValues={getFilterValue("filter[provider_id__in]")}
|
||||
selectedProviderTypes={getFilterValue("filter[provider_type__in]")}
|
||||
/>
|
||||
</div>
|
||||
{hasCustomFilters && (
|
||||
<Button
|
||||
@@ -82,13 +123,42 @@ export const ResourcesFilters = ({
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
<ClearFiltersButton showCount />
|
||||
<ClearFiltersButton
|
||||
showCount
|
||||
onClear={clearAndApply}
|
||||
pendingCount={
|
||||
Object.values(pendingFilters).filter((values) => values.length > 0)
|
||||
.length
|
||||
}
|
||||
/>
|
||||
<ApplyFiltersButton
|
||||
hasChanges={hasChanges}
|
||||
changeCount={changeCount}
|
||||
onApply={applyAll}
|
||||
onDiscard={discardAll}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FilterSummaryStrip chips={filterChips} onRemove={handleChipRemove} />
|
||||
|
||||
{/* Expandable filters section */}
|
||||
{hasCustomFilters && (
|
||||
<ExpandableSection isExpanded={isExpanded}>
|
||||
<DataTableFilterCustom filters={customFilters} hideClearButton />
|
||||
<DataTableFilterCustom
|
||||
filters={customFilters.map((filter) => ({
|
||||
...filter,
|
||||
labelFormatter: (value: string) =>
|
||||
getResourcesFilterDisplayValue(
|
||||
`filter[${filter.key}]`,
|
||||
value,
|
||||
providers,
|
||||
),
|
||||
}))}
|
||||
hideClearButton
|
||||
mode={DATA_TABLE_FILTER_MODE.BATCH}
|
||||
onBatchChange={setPending}
|
||||
getFilterValue={getFilterValue}
|
||||
/>
|
||||
</ExpandableSection>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { FilterChip } from "@/components/filters/filter-summary-strip";
|
||||
import { formatLabel, getGroupLabel } from "@/lib/categories";
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
import { getProviderDisplayName } from "@/types/providers";
|
||||
|
||||
const RESOURCE_FILTER_KEY_LABELS: Record<string, string> = {
|
||||
"filter[provider_type__in]": "Provider",
|
||||
"filter[provider_id__in]": "Account",
|
||||
"filter[region__in]": "Region",
|
||||
"filter[service__in]": "Service",
|
||||
"filter[type__in]": "Type",
|
||||
"filter[groups__in]": "Group",
|
||||
};
|
||||
|
||||
function getProviderAccountDisplayValue(
|
||||
providerId: string,
|
||||
providers: ProviderProps[],
|
||||
): string {
|
||||
const provider = providers.find((item) => item.id === providerId);
|
||||
if (!provider) {
|
||||
return providerId;
|
||||
}
|
||||
|
||||
return provider.attributes.alias || provider.attributes.uid || providerId;
|
||||
}
|
||||
|
||||
export function getResourcesFilterDisplayValue(
|
||||
filterKey: string,
|
||||
value: string,
|
||||
providers: ProviderProps[],
|
||||
): string {
|
||||
if (!value) return value;
|
||||
|
||||
if (filterKey === "filter[provider_type__in]") {
|
||||
return getProviderDisplayName(value);
|
||||
}
|
||||
|
||||
if (filterKey === "filter[provider_id__in]") {
|
||||
return getProviderAccountDisplayValue(value, providers);
|
||||
}
|
||||
|
||||
if (filterKey === "filter[groups__in]") {
|
||||
return getGroupLabel(value);
|
||||
}
|
||||
|
||||
if (filterKey === "filter[type__in]") {
|
||||
return formatLabel(value, "_");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function buildResourcesFilterChips(
|
||||
pendingFilters: Record<string, string[]>,
|
||||
providers: ProviderProps[],
|
||||
): FilterChip[] {
|
||||
const chips: FilterChip[] = [];
|
||||
|
||||
Object.entries(pendingFilters).forEach(([key, values]) => {
|
||||
if (!values || values.length === 0) return;
|
||||
|
||||
const label = RESOURCE_FILTER_KEY_LABELS[key] ?? key;
|
||||
|
||||
values.forEach((value) => {
|
||||
chips.push({
|
||||
key,
|
||||
label,
|
||||
value,
|
||||
displayValue: getResourcesFilterDisplayValue(key, value, providers),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return chips;
|
||||
}
|
||||
@@ -26,4 +26,15 @@ describe("resource detail content", () => {
|
||||
expect(source).not.toContain("useEffect");
|
||||
expect(source).not.toContain("useEffect(");
|
||||
});
|
||||
|
||||
it("renders resource metadata with the shared code editor JSON highlighting instead of a plain pre block", () => {
|
||||
expect(source).toContain("QueryCodeEditor");
|
||||
expect(source).toContain("QUERY_EDITOR_LANGUAGE.JSON");
|
||||
expect(source).not.toContain("<pre");
|
||||
});
|
||||
|
||||
it("refreshes findings locally after mute without forcing a router refresh", () => {
|
||||
expect(source).toContain("setFindingsReloadNonce");
|
||||
expect(source).not.toContain("router.refresh()");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Row, RowSelectionState } from "@tanstack/react-table";
|
||||
import {
|
||||
Check,
|
||||
Container,
|
||||
Copy,
|
||||
CornerDownRight,
|
||||
ExternalLink,
|
||||
Link,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Container, CornerDownRight, ExternalLink, Link } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { FloatingMuteButton } from "@/components/findings/floating-mute-button";
|
||||
@@ -30,6 +22,10 @@ import {
|
||||
} from "@/components/shadcn/info-field/info-field";
|
||||
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
|
||||
import { EventsTimeline } from "@/components/shared/events-timeline/events-timeline";
|
||||
import {
|
||||
QUERY_EDITOR_LANGUAGE,
|
||||
QueryCodeEditor,
|
||||
} from "@/components/shared/query-code-editor";
|
||||
import { BreadcrumbNavigation, CustomBreadcrumbItem } from "@/components/ui";
|
||||
import { DateWithTime } from "@/components/ui/entities/date-with-time";
|
||||
import { EntityInfo } from "@/components/ui/entities/entity-info";
|
||||
@@ -115,11 +111,9 @@ export const ResourceDetailContent = ({
|
||||
);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
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 router = useRouter();
|
||||
|
||||
const resource = resourceDetails;
|
||||
const resourceId = resource.id;
|
||||
@@ -155,12 +149,6 @@ export const ResourceDetailContent = ({
|
||||
navigator.clipboard.writeText(url);
|
||||
};
|
||||
|
||||
const copyMetadata = async (metadata: Record<string, unknown>) => {
|
||||
await navigator.clipboard.writeText(JSON.stringify(metadata, null, 2));
|
||||
setMetadataCopied(true);
|
||||
setTimeout(() => setMetadataCopied(false), 2000);
|
||||
};
|
||||
|
||||
const navigateToFinding = async (findingId: string) => {
|
||||
setSelectedFindingId(findingId);
|
||||
await loadFindingDetails(findingId);
|
||||
@@ -177,7 +165,6 @@ export const ResourceDetailContent = ({
|
||||
|
||||
setRowSelection({});
|
||||
if (ids.length > 0) setFindingsReloadNonce((v) => v + 1);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const failedFindings = findingsData;
|
||||
@@ -452,30 +439,16 @@ export const ResourceDetailContent = ({
|
||||
)}
|
||||
|
||||
{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>
|
||||
<QueryCodeEditor
|
||||
ariaLabel="Resource metadata"
|
||||
language={QUERY_EDITOR_LANGUAGE.JSON}
|
||||
value={JSON.stringify(parsedMetadata, null, 2)}
|
||||
copyValue={JSON.stringify(parsedMetadata, null, 2)}
|
||||
editable={false}
|
||||
minHeight={220}
|
||||
showCopyButton
|
||||
onChange={() => {}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!attributes.details?.trim() && !hasMetadata && (
|
||||
@@ -487,20 +460,13 @@ export const ResourceDetailContent = ({
|
||||
|
||||
<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>
|
||||
<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>
|
||||
) : (
|
||||
<p className="text-text-neutral-tertiary py-8 text-center text-sm">
|
||||
No tags available for this resource.
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
import {
|
||||
type ComponentPropsWithoutRef,
|
||||
type ElementRef,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
@@ -81,21 +86,22 @@ function CommandInput({
|
||||
);
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const CommandList = forwardRef<
|
||||
ElementRef<typeof CommandPrimitive.List>,
|
||||
ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
|
||||
@@ -283,13 +283,33 @@ export function MultiSelectContent({
|
||||
children: ReactNode;
|
||||
width?: "default" | "wide";
|
||||
} & Omit<ComponentPropsWithoutRef<typeof Command>, "children">) {
|
||||
const { open } = useMultiSelectContext();
|
||||
const canSearch = typeof search === "object" ? true : search;
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const widthClasses =
|
||||
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]";
|
||||
|
||||
useEffect(() => {
|
||||
if (open) return;
|
||||
setSearchValue("");
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canSearch || !searchValue.trim()) return;
|
||||
|
||||
const firstVisibleItem = listRef.current?.querySelector<HTMLElement>(
|
||||
'[data-slot="multiselect-item"]:not([hidden])',
|
||||
);
|
||||
|
||||
firstVisibleItem?.scrollIntoView({
|
||||
block: "nearest",
|
||||
});
|
||||
}, [canSearch, searchValue, children]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="hidden" aria-hidden="true">
|
||||
@@ -312,11 +332,16 @@ export function MultiSelectContent({
|
||||
typeof search === "object" ? search.placeholder : undefined
|
||||
}
|
||||
className="text-bg-button-secondary placeholder:text-bg-button-secondary"
|
||||
value={searchValue}
|
||||
onValueChange={setSearchValue}
|
||||
/>
|
||||
) : (
|
||||
<button className="sr-only" />
|
||||
)}
|
||||
<CommandList className="minimal-scrollbar max-h-[300px] overflow-x-hidden overflow-y-auto p-3">
|
||||
<CommandList
|
||||
ref={listRef}
|
||||
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}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { cn } from "@/lib/utils";
|
||||
export const QUERY_EDITOR_LANGUAGE = {
|
||||
OPEN_CYPHER: "openCypher",
|
||||
PLAIN_TEXT: "plainText",
|
||||
JSON: "json",
|
||||
SHELL: "shell",
|
||||
HCL: "hcl",
|
||||
BICEP: "bicep",
|
||||
@@ -205,6 +206,74 @@ const HCL_FUNCTIONS = new Set([
|
||||
"cidrhost",
|
||||
]);
|
||||
|
||||
interface JsonParserState {
|
||||
inString: boolean;
|
||||
stringIsProperty: boolean;
|
||||
escapeNext: boolean;
|
||||
}
|
||||
|
||||
const jsonLanguage = StreamLanguage.define<JsonParserState>({
|
||||
startState() {
|
||||
return {
|
||||
inString: false,
|
||||
stringIsProperty: false,
|
||||
escapeNext: false,
|
||||
};
|
||||
},
|
||||
token(stream, state) {
|
||||
if (state.inString) {
|
||||
while (!stream.eol()) {
|
||||
const next = stream.next();
|
||||
|
||||
if (state.escapeNext) {
|
||||
state.escapeNext = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (next === "\\") {
|
||||
state.escapeNext = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (next === '"') {
|
||||
state.inString = false;
|
||||
return state.stringIsProperty ? "propertyName" : "string";
|
||||
}
|
||||
}
|
||||
|
||||
return state.stringIsProperty ? "propertyName" : "string";
|
||||
}
|
||||
|
||||
if (stream.eatSpace()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (stream.peek() === '"') {
|
||||
const restOfLine = stream.string.slice(stream.pos);
|
||||
state.inString = true;
|
||||
state.escapeNext = false;
|
||||
state.stringIsProperty = /^\s*"([^"\\]|\\.)*"\s*:/.test(restOfLine);
|
||||
stream.next();
|
||||
return state.stringIsProperty ? "propertyName" : "string";
|
||||
}
|
||||
|
||||
if (stream.match(/[{}\[\],:]/)) {
|
||||
return "punctuation";
|
||||
}
|
||||
|
||||
if (stream.match(/-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/)) {
|
||||
return "number";
|
||||
}
|
||||
|
||||
if (stream.match(/\b(?:true|false|null)\b/)) {
|
||||
return "keyword";
|
||||
}
|
||||
|
||||
stream.next();
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
const BICEP_KEYWORDS = new Set([
|
||||
"resource",
|
||||
"module",
|
||||
@@ -1171,6 +1240,8 @@ export const QueryCodeEditor = ({
|
||||
extensions.push(shellLanguage, syntaxHighlighting(editorHighlightStyle));
|
||||
} else if (language === QUERY_EDITOR_LANGUAGE.HCL) {
|
||||
extensions.push(hclLanguage, syntaxHighlighting(editorHighlightStyle));
|
||||
} else if (language === QUERY_EDITOR_LANGUAGE.JSON) {
|
||||
extensions.push(jsonLanguage, syntaxHighlighting(editorHighlightStyle));
|
||||
} else if (language === QUERY_EDITOR_LANGUAGE.BICEP) {
|
||||
extensions.push(bicepLanguage, syntaxHighlighting(editorHighlightStyle));
|
||||
} else if (language === QUERY_EDITOR_LANGUAGE.YAML) {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { MetaDataProps } from "@/types";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/providers",
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
getPaginationInfo: () => ({
|
||||
currentPage: 2,
|
||||
totalPages: 4,
|
||||
totalEntries: 40,
|
||||
itemsPerPageOptions: [10, 20, 50],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/select/select", () => ({
|
||||
Select: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectItem: ({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value: string;
|
||||
}) => <option value={value}>{children}</option>,
|
||||
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<button type="button">{children}</button>
|
||||
),
|
||||
SelectValue: () => <span>10</span>,
|
||||
}));
|
||||
|
||||
import { DataTablePagination } from "./data-table-pagination";
|
||||
|
||||
const metadata: MetaDataProps = {
|
||||
pagination: {
|
||||
page: 2,
|
||||
pages: 4,
|
||||
count: 40,
|
||||
itemsPerPage: [10, 20, 50],
|
||||
},
|
||||
version: "latest",
|
||||
};
|
||||
|
||||
describe("DataTablePagination", () => {
|
||||
it("keeps navigation arrows visible on hover in light theme", () => {
|
||||
render(<DataTablePagination metadata={metadata} />);
|
||||
|
||||
expect(screen.getByLabelText("Go to first page")).toHaveClass(
|
||||
"hover:text-text-neutral-primary",
|
||||
);
|
||||
expect(screen.getByLabelText("Go to first page")).toHaveClass(
|
||||
"hover:bg-bg-neutral-tertiary",
|
||||
);
|
||||
expect(screen.getByLabelText("Go to next page")).toHaveClass(
|
||||
"hover:text-text-neutral-primary",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,8 @@ interface DataTablePaginationProps {
|
||||
|
||||
const NAV_BUTTON_STYLES = {
|
||||
base: "flex items-center justify-center rounded-full p-3 transition-colors",
|
||||
enabled: "text-text-neutral-secondary hover:text-white",
|
||||
enabled:
|
||||
"text-text-neutral-secondary hover:bg-bg-neutral-tertiary hover:text-text-neutral-primary",
|
||||
disabled: "text-text-neutral-tertiary cursor-not-allowed pointer-events-none",
|
||||
} as const;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user