Compare commits

..

1 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
110 changed files with 2541 additions and 4008 deletions

23
.github/CODEOWNERS vendored
View File

@@ -1,15 +1,14 @@
# SDK
/* @prowler-cloud/detection-remediation
/prowler/ @prowler-cloud/detection-remediation
/prowler/compliance/ @prowler-cloud/compliance
/tests/ @prowler-cloud/detection-remediation
/dashboard/ @prowler-cloud/detection-remediation
/docs/ @prowler-cloud/detection-remediation
/examples/ @prowler-cloud/detection-remediation
/util/ @prowler-cloud/detection-remediation
/contrib/ @prowler-cloud/detection-remediation
/permissions/ @prowler-cloud/detection-remediation
/codecov.yml @prowler-cloud/detection-remediation @prowler-cloud/api
/* @prowler-cloud/sdk
/prowler/ @prowler-cloud/sdk @prowler-cloud/detection-and-remediation
/tests/ @prowler-cloud/sdk @prowler-cloud/detection-and-remediation
/dashboard/ @prowler-cloud/sdk
/docs/ @prowler-cloud/sdk
/examples/ @prowler-cloud/sdk
/util/ @prowler-cloud/sdk
/contrib/ @prowler-cloud/sdk
/permissions/ @prowler-cloud/sdk
/codecov.yml @prowler-cloud/sdk @prowler-cloud/api
# API
/api/ @prowler-cloud/api
@@ -18,7 +17,7 @@
/ui/ @prowler-cloud/ui
# AI
/mcp_server/ @prowler-cloud/detection-remediation
/mcp_server/ @prowler-cloud/ai
# Platform
/.github/ @prowler-cloud/platform

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

@@ -1,115 +0,0 @@
{
"name": ".opencode",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@opencode-ai/plugin": "1.3.17"
}
},
"node_modules/@opencode-ai/plugin": {
"version": "1.3.17",
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.3.17.tgz",
"integrity": "sha512-N5lckFtYvEu2R8K1um//MIOTHsJHniF2kHoPIWPCrxKG5Jpismt1ISGzIiU3aKI2ht/9VgcqKPC5oZFLdmpxPw==",
"license": "MIT",
"dependencies": {
"@opencode-ai/sdk": "1.3.17",
"zod": "4.1.8"
},
"peerDependencies": {
"@opentui/core": ">=0.1.96",
"@opentui/solid": ">=0.1.96"
},
"peerDependenciesMeta": {
"@opentui/core": {
"optional": true
},
"@opentui/solid": {
"optional": true
}
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.3.17",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.3.17.tgz",
"integrity": "sha512-2+MGgu7wynqTBwxezR01VAGhILXlpcHDY/pF7SWB87WOgLt3kD55HjKHNj6PWxyY8n575AZolR95VUC3gtwfmA==",
"license": "MIT",
"dependencies": {
"cross-spawn": "7.0.6"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/zod": {
"version": "4.1.8",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -7,12 +7,6 @@ All notable changes to the **Prowler API** are documented in this file.
### 🔄 Changed
- Bump Poetry to `2.3.4` in Dockerfile and pre-commit hooks. Regenerate `api/poetry.lock` [(#10681)](https://github.com/prowler-cloud/prowler/pull/10681)
- Attack Paths: Remove dead `cleanup_findings` no-op and its supporting `prowler_finding_lastupdated` index [(#10684)](https://github.com/prowler-cloud/prowler/pull/10684)
### 🐞 Fixed
- Worker-beat race condition on cold start: replaced `sleep 15` with API service healthcheck dependency (Docker Compose) and init containers (Helm), aligned Gunicorn default port to `8080` [(#10603)](https://github.com/prowler-cloud/prowler/pull/10603)
- API container startup crash on Linux due to root-owned bind-mount preventing JWT key generation [(#10646)](https://github.com/prowler-cloud/prowler/pull/10646)
### 🔐 Security

View File

@@ -56,6 +56,7 @@ start_worker() {
start_worker_beat() {
echo "Starting the worker-beat..."
sleep 15
poetry run python -m celery -A config.celery beat -l "${DJANGO_LOGGING_LEVEL:-info}" --scheduler django_celery_beat.schedulers:DatabaseScheduler
}

View File

@@ -15,7 +15,7 @@ from config.django.production import LOGGING as DJANGO_LOGGERS, DEBUG # noqa: E
from config.custom_logging import BackendLogger # noqa: E402
BIND_ADDRESS = env("DJANGO_BIND_ADDRESS", default="127.0.0.1")
PORT = env("DJANGO_PORT", default=8080)
PORT = env("DJANGO_PORT", default=8000)
# Server settings
bind = f"{BIND_ADDRESS}:{PORT}"

View File

@@ -5,6 +5,7 @@ This module handles:
- Adding resource labels to Cartography nodes for efficient lookups
- Loading Prowler findings into the graph
- Linking findings to resources
- Cleaning up stale findings
"""
from collections import defaultdict
@@ -23,6 +24,7 @@ from tasks.jobs.attack_paths.config import (
)
from tasks.jobs.attack_paths.queries import (
ADD_RESOURCE_LABEL_TEMPLATE,
CLEANUP_FINDINGS_TEMPLATE,
INSERT_FINDING_TEMPLATE,
render_cypher_template,
)
@@ -90,13 +92,14 @@ def analysis(
"""
Main entry point for Prowler findings analysis.
Adds resource labels and loads findings.
Adds resource labels, loads findings, and cleans up stale data.
"""
add_resource_label(
neo4j_session, prowler_api_provider.provider, str(prowler_api_provider.uid)
)
findings_data = stream_findings_with_resources(prowler_api_provider, scan_id)
load_findings(neo4j_session, findings_data, prowler_api_provider, config)
cleanup_findings(neo4j_session, prowler_api_provider, config)
def add_resource_label(
@@ -180,6 +183,28 @@ def load_findings(
logger.info(f"Finished loading {total_records} records in {batch_num} batches")
def cleanup_findings(
neo4j_session: neo4j.Session,
prowler_api_provider: Provider,
config: CartographyConfig,
) -> None:
"""Remove stale findings (classic Cartography behaviour)."""
parameters = {
"last_updated": config.update_tag,
"batch_size": BATCH_SIZE,
}
batch = 1
deleted_count = 1
while deleted_count > 0:
logger.info(f"Cleaning findings batch {batch}")
result = neo4j_session.run(CLEANUP_FINDINGS_TEMPLATE, parameters)
deleted_count = result.single().get("deleted_findings_count", 0)
batch += 1
# Findings Streaming (Generator-based)
# -------------------------------------

View File

@@ -13,13 +13,14 @@ from tasks.jobs.attack_paths.config import (
logger = get_task_logger(__name__)
# Indexes for Prowler Findings and resource lookups
# Indexes for Prowler findings and resource lookups
FINDINGS_INDEX_STATEMENTS = [
# Resource indexes for Prowler Finding lookups
"CREATE INDEX aws_resource_arn IF NOT EXISTS FOR (n:_AWSResource) ON (n.arn);",
"CREATE INDEX aws_resource_id IF NOT EXISTS FOR (n:_AWSResource) ON (n.id);",
# Prowler Finding indexes
f"CREATE INDEX prowler_finding_id IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.id);",
f"CREATE INDEX prowler_finding_lastupdated IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.lastupdated);",
f"CREATE INDEX prowler_finding_status IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.status);",
# Internet node index for MERGE lookups
f"CREATE INDEX internet_id IF NOT EXISTS FOR (n:{INTERNET_NODE_LABEL}) ON (n.id);",

View File

@@ -80,6 +80,17 @@ INSERT_FINDING_TEMPLATE = f"""
rel.lastupdated = $last_updated
"""
CLEANUP_FINDINGS_TEMPLATE = f"""
MATCH (finding:{PROWLER_FINDING_LABEL})
WHERE finding.lastupdated < $last_updated
WITH finding LIMIT $batch_size
DETACH DELETE finding
RETURN COUNT(finding) AS deleted_findings_count
"""
# Internet queries (used by internet.py)
# ---------------------------------------

View File

@@ -1298,6 +1298,23 @@ class TestAttackPathsFindingsHelpers:
assert params["last_updated"] == config.update_tag
assert "findings_data" in params
def test_cleanup_findings_runs_batches(self, providers_fixture):
provider = providers_fixture[0]
config = SimpleNamespace(update_tag=1024)
mock_session = MagicMock()
first_batch = MagicMock()
first_batch.single.return_value = {"deleted_findings_count": 3}
second_batch = MagicMock()
second_batch.single.return_value = {"deleted_findings_count": 0}
mock_session.run.side_effect = [first_batch, second_batch]
findings_module.cleanup_findings(mock_session, provider, config)
assert mock_session.run.call_count == 2
params = mock_session.run.call_args.args[1]
assert params["last_updated"] == config.update_tag
def test_stream_findings_with_resources_returns_latest_scan_data(
self,
tenants_fixture,

View File

@@ -34,10 +34,6 @@ spec:
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.worker.initContainers }}
initContainers:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: worker
{{- with .Values.worker.securityContext }}

View File

@@ -32,10 +32,6 @@ spec:
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.worker_beat.initContainers }}
initContainers:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: worker-beat
{{- with .Values.worker_beat.securityContext }}

View File

@@ -1,11 +1,4 @@
services:
api-dev-init:
image: busybox:1.37.0
volumes:
- ./_data/api:/data
command: ["sh", "-c", "chown -R 1000:1000 /data"]
restart: "no"
api-dev:
hostname: "prowler-api"
image: prowler-api-dev
@@ -28,20 +21,12 @@ services:
- ./_data/api:/home/prowler/.config/prowler-api
- outputs:/tmp/prowler_api_output
depends_on:
api-dev-init:
condition: service_completed_successfully
postgres:
condition: service_healthy
valkey:
condition: service_healthy
neo4j:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:${DJANGO_PORT:-8080}/api/v1/ || exit 1"]
interval: 10s
timeout: 5s
retries: 12
start_period: 60s
entrypoint:
- "/home/prowler/docker-entrypoint.sh"
- "dev"
@@ -154,7 +139,11 @@ services:
- ./api/docker-entrypoint.sh:/home/prowler/docker-entrypoint.sh
- outputs:/tmp/prowler_api_output
depends_on:
api-dev:
valkey:
condition: service_healthy
postgres:
condition: service_healthy
neo4j:
condition: service_healthy
ulimits:
nofile:
@@ -176,7 +165,11 @@ services:
- path: ./.env
required: false
depends_on:
api-dev:
valkey:
condition: service_healthy
postgres:
condition: service_healthy
neo4j:
condition: service_healthy
ulimits:
nofile:

View File

@@ -5,13 +5,6 @@
# docker compose -f docker-compose-dev.yml up
#
services:
api-init:
image: busybox:1.37.0
volumes:
- ./_data/api:/data
command: ["sh", "-c", "chown -R 1000:1000 /data"]
restart: "no"
api:
hostname: "prowler-api"
image: prowlercloud/prowler-api:${PROWLER_API_VERSION:-stable}
@@ -24,20 +17,12 @@ services:
- ./_data/api:/home/prowler/.config/prowler-api
- output:/tmp/prowler_api_output
depends_on:
api-init:
condition: service_completed_successfully
postgres:
condition: service_healthy
valkey:
condition: service_healthy
neo4j:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:${DJANGO_PORT:-8080}/api/v1/ || exit 1"]
interval: 10s
timeout: 5s
retries: 12
start_period: 60s
entrypoint:
- "/home/prowler/docker-entrypoint.sh"
- "prod"
@@ -129,7 +114,9 @@ services:
volumes:
- "output:/tmp/prowler_api_output"
depends_on:
api:
valkey:
condition: service_healthy
postgres:
condition: service_healthy
ulimits:
nofile:
@@ -145,7 +132,9 @@ services:
- path: ./.env
required: false
depends_on:
api:
valkey:
condition: service_healthy
postgres:
condition: service_healthy
ulimits:
nofile:

View File

@@ -15,7 +15,8 @@ This document describes the internal architecture of Prowler Lighthouse AI, enab
Lighthouse AI operates as a Langchain-based agent that connects Large Language Models (LLMs) with Prowler security data through the Model Context Protocol (MCP).
![Prowler Lighthouse Architecture](/images/lighthouse-architecture.png)
<img className="block dark:hidden" src="/images/lighthouse-architecture-light.png" alt="Prowler Lighthouse Architecture" />
<img className="hidden dark:block" src="/images/lighthouse-architecture-dark.png" alt="Prowler Lighthouse Architecture" />
### Three-Tier Architecture

View File

@@ -12,24 +12,6 @@
"dark": "/images/prowler-logo-white.png",
"light": "/images/prowler-logo-black.png"
},
"contextual": {
"options": [
"copy",
"view",
{
"title": "Request a feature",
"description": "Open a feature request on GitHub",
"icon": "plus",
"href": "https://github.com/prowler-cloud/prowler/issues/new?template=feature-request.yml"
},
{
"title": "Report an issue",
"description": "Open a bug report on GitHub",
"icon": "bug",
"href": "https://github.com/prowler-cloud/prowler/issues/new?template=bug_report.yml"
}
]
},
"navigation": {
"tabs": [
{
@@ -151,7 +133,6 @@
]
},
"user-guide/tutorials/prowler-app-attack-paths",
"user-guide/tutorials/prowler-app-finding-groups",
"user-guide/tutorials/prowler-cloud-public-ips",
{
"group": "Tutorials",

View File

@@ -59,10 +59,6 @@ Prowler Lighthouse AI is powerful, but there are limitations:
- **NextJS session dependence**: If your Prowler application session expires or logs out, Lighthouse AI will error out. Refresh and log back in to continue.
- **Response quality**: The response quality depends on the selected LLM provider and model. Choose models with strong tool-calling capabilities for best results. We recommend `gpt-5` model from OpenAI.
## Architecture
![Prowler Lighthouse Architecture](/images/lighthouse-architecture.png)
## Extending Lighthouse AI
Lighthouse AI retrieves data through Prowler MCP. To add new capabilities, extend the Prowler MCP Server with additional tools and Lighthouse AI discovers them automatically.

View File

@@ -46,7 +46,8 @@ Search and retrieve official Prowler documentation:
The following diagram illustrates the Prowler MCP Server architecture and its integration points:
![Prowler MCP Server Schema](/images/prowler_mcp_schema.png)
<img className="block dark:hidden" src="/images/prowler_mcp_schema_light.png" alt="Prowler MCP Server Schema" />
<img className="hidden dark:block" src="/images/prowler_mcp_schema_dark.png" alt="Prowler MCP Server Schema" />
The architecture shows how AI assistants connect through the MCP protocol to access Prowler's three main components:
- Prowler Cloud/App for security operations

Binary file not shown.

Before

Width:  |  Height:  |  Size: 755 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

View File

@@ -1,37 +0,0 @@
flowchart TB
browser([Browser])
subgraph NEXTJS["Next.js Server"]
route["API Route<br/>(auth + context assembly)"]
agent["LangChain Agent"]
subgraph TOOLS["Agent Tools"]
metatools["Meta-tools<br/>describe_tool / execute_tool / load_skill"]
end
mcpclient["MCP Client<br/>(HTTP transport)"]
end
llm["LLM Provider<br/>(OpenAI / Bedrock / OpenAI-compatible)"]
subgraph MCP["Prowler MCP Server"]
app_tools["prowler_app_* tools<br/>(auth required)"]
hub_tools["prowler_hub_* tools<br/>(no auth)"]
docs_tools["prowler_docs_* tools<br/>(no auth)"]
end
api["Prowler API"]
hub["hub.prowler.com"]
docs["docs.prowler.com<br/>(Mintlify)"]
browser <-->|SSE stream| route
route --> agent
agent <-->|LLM API| llm
agent --> metatools
metatools --> mcpclient
mcpclient -->|MCP HTTP · Bearer token<br/>for prowler_app_* only| app_tools
mcpclient -->|MCP HTTP| hub_tools
mcpclient -->|MCP HTTP| docs_tools
app_tools -->|REST| api
hub_tools -->|REST| hub
docs_tools -->|REST| docs

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

View File

@@ -23,8 +23,6 @@ flowchart TB
user --> ui
user --> cli
ui -->|REST| api
ui -->|MCP HTTP| mcp
mcp -->|REST| api
api --> pg
api --> valkey
beat -->|enqueue jobs| valkey
@@ -33,5 +31,7 @@ flowchart TB
worker -->|Attack Paths| neo4j
worker -->|invokes| sdk
cli --> sdk
api -. AI tools .-> mcp
mcp -. context .-> api
sdk --> providers

Binary file not shown.

Before

Width:  |  Height:  |  Size: 348 KiB

After

Width:  |  Height:  |  Size: 268 KiB

View File

@@ -1,29 +0,0 @@
flowchart LR
subgraph HOSTS["MCP Hosts"]
chat["Chat Interfaces<br/>(Claude Desktop, LobeChat)"]
ide["IDEs and Code Editors<br/>(Claude Code, Cursor)"]
apps["Other AI Applications<br/>(5ire, custom agents)"]
end
subgraph MCP["Prowler MCP Server"]
app_tools["prowler_app_* tools<br/>(JWT or API key auth)<br/>Findings · Providers · Scans<br/>Resources · Muting · Compliance<br/>Attack Paths"]
hub_tools["prowler_hub_* tools<br/>(no auth)<br/>Checks Catalog · Check Code<br/>Fixers · Compliance Frameworks"]
docs_tools["prowler_docs_* tools<br/>(no auth)<br/>Search · Document Retrieval"]
end
api["Prowler API<br/>(REST)"]
hub["hub.prowler.com<br/>(REST)"]
docs["docs.prowler.com<br/>(Mintlify)"]
chat -->|STDIO or HTTP| app_tools
chat -->|STDIO or HTTP| hub_tools
chat -->|STDIO or HTTP| docs_tools
ide -->|STDIO or HTTP| app_tools
ide -->|STDIO or HTTP| hub_tools
ide -->|STDIO or HTTP| docs_tools
apps -->|STDIO or HTTP| app_tools
apps -->|STDIO or HTTP| hub_tools
apps -->|STDIO or HTTP| docs_tools
app_tools -->|REST| api
hub_tools -->|REST| hub
docs_tools -->|REST| docs

Binary file not shown.

Before

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

View File

@@ -1,119 +0,0 @@
---
title: 'Finding Groups'
description: 'Organize and triage security findings by check to reduce noise and prioritize remediation effectively.'
---
import { VersionBadge } from "/snippets/version-badge.mdx"
<VersionBadge version="5.23.0" />
Finding Groups transforms security findings triage by grouping them by check instead of displaying a flat list. This dramatically reduces noise and enables faster, more effective prioritization.
## Triage Challenges with Flat Finding Lists
A real cloud environment produces thousands of findings per scan. A flat list makes it impossible to triage effectively:
- **Signal buried in noise**: the same misconfiguration repeated across 200 resources shows up as 200 rows, burying the signal in repetitive data
- **Prioritization guesswork**: without grouping, understanding which issues affect the most resources requires manual counting and correlation
- **Tedious muting**: muting a false positive globally requires manually acting on each individual finding across the list
- **Lost context**: when investigating a single resource, related findings are scattered across the same flat list, making it hard to see the full picture
## How Finding Groups Addresses These Challenges
Finding Groups addresses these challenges by intelligently grouping findings by check.
### Grouped View at a Glance
Each row represents a single check title with key information immediately visible:
- **Severity** indicator for quick risk assessment
- **Impacted providers** showing which cloud platforms are affected
- **X of Y impacted resources** counter displaying how many resources fail this check
For example, `Vercel project has the Web Application Firewall enabled` across every affected project collapses to a single row — not one per project. Sort or filter by severity, provider, or status at the group level to triage top-down instead of drowning in per-resource rows.
![Finding Groups list view](/images/finding-groups-list.png)
### Expanding Groups for Details
Expand any group inline to see the failing resources with detailed information:
| Column | Description |
|--------|-------------|
| **UID** | Unique identifier for the resource |
| **Service** | The cloud service the resource belongs to |
| **Region** | Geographic region where the resource is deployed |
| **Severity** | Risk level of the finding |
| **Provider** | Cloud provider (AWS, Azure, GCP, Kubernetes, etc.) |
| **Last Seen** | When the finding was last detected |
| **Failing For** | Duration the resource has been in a failing state |
![Finding Groups expanded view](/images/finding-groups-expanded.png)
### Resource Detail Drawer
Select any resource to open the detail drawer with full finding context:
- **Risk**: the security risk associated with this finding
- **Description**: detailed explanation of what was detected
- **Status Extended**: additional status information and context
- **Remediation**: step-by-step guidance to resolve the issue
- **View in Prowler Hub**: direct link to explore the check in Prowler Hub
- **Analyze This Finding With Lighthouse AI**: one-click AI-powered analysis for deeper insights
![Finding Groups resource detail drawer](/images/finding-groups-drawer.png)
### Bulk Actions
Bulk-mute an entire group instead of chasing duplicates across the list. This is especially useful for:
- Known false positives that appear across many resources
- Findings in development or test environments
- Accepted risks that have been documented and approved
<Warning>
Muting findings does not resolve underlying security issues. Review each finding carefully before muting to ensure it represents an acceptable risk or has been properly addressed.
</Warning>
## Other Findings for This Resource
Inside the resource detail drawer, the **Other Findings For This Resource** tab lists every finding that hits the same resource — passing, failing, and muted — alongside the one currently being reviewed.
![Other Findings For This Resource tab](/images/finding-groups-other-findings.png)
### Why This Matters
When reviewing "WAF not enabled" on a Vercel project, the tab immediately shows:
- Skew protection status
- Rate limiting configuration
- IP blocking settings
- Custom firewall rules
- Password protection findings
All for that same project, without navigating back to the main list and filtering by resource UID.
### Complete Context Within the Drawer
Pair the Other Findings tab with:
- **Scans tab**: scan history for this resource
- **Events tab**: changes and events over time
This provides full context without leaving the drawer.
## Best Practices
1. **Start with high severity groups**: focus on critical and high severity groups first for maximum impact.
2. **Use filters strategically**: filter by provider or status at the group level to narrow the triage scope.
3. **Leverage bulk mute**: when a finding represents a confirmed false positive, mute the entire group at once.
4. **Check related findings**: review the Other Findings tab to understand the full security posture of a resource.
5. **Track failure duration**: use the "Failing For" column to prioritize long-standing issues that may indicate systemic problems.
## Getting Started
1. Navigate to the **Findings** section in Prowler Cloud/App.
2. Toggle to the **Grouped View** to see findings organized by check.
3. Select any group row to expand and see affected resources.
4. Select a resource to open the detail drawer with full context.
5. Use the **Other Findings For This Resource** tab to see all findings for that resource.

View File

@@ -25,7 +25,8 @@ Behind the scenes, Lighthouse AI works as follows:
Lighthouse AI supports multiple LLM providers including OpenAI, Amazon Bedrock, and OpenAI-compatible services. For configuration details, see [Using Multiple LLM Providers with Lighthouse](/user-guide/tutorials/prowler-app-lighthouse-multi-llm).
</Note>
![Prowler Lighthouse Architecture](/images/lighthouse-architecture.png)
<img className="block dark:hidden" src="/images/lighthouse-architecture-light.png" alt="Prowler Lighthouse Architecture" />
<img className="hidden dark:block" src="/images/lighthouse-architecture-dark.png" alt="Prowler Lighthouse Architecture" />
<Note>

View File

@@ -23,9 +23,6 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Bump Poetry to `2.3.4` and consolidate SDK workflows onto the `setup-python-poetry` composite action with opt-in lockfile regeneration [(#10681)](https://github.com/prowler-cloud/prowler/pull/10681)
- Normalize Conditional Access platform values in Entra models and simplify platform-based checks [(#10635)](https://github.com/prowler-cloud/prowler/pull/10635)
### 🐞 Fixed
- Vercel firewall config handling for team-scoped projects and current API response shapes [(#10695)](https://github.com/prowler-cloud/prowler/pull/10695)
---
## [5.23.0] (Prowler v5.23.0)

View File

@@ -55,7 +55,6 @@ class Project(VercelService):
# Parse password protection
pwd_protection = proj.get("passwordProtection")
security = proj.get("security", {}) or {}
self.projects[project_id] = VercelProject(
id=project_id,
@@ -76,16 +75,6 @@ class Project(VercelService):
git_fork_protection=proj.get("gitForkProtection", True),
git_repository=proj.get("link"),
secure_compute=proj.get("secureCompute"),
firewall_enabled=security.get("firewallEnabled"),
firewall_config_version=(
str(security.get("firewallConfigVersion"))
if security.get("firewallConfigVersion") is not None
else None
),
managed_rules=security.get(
"managedRules", security.get("managedRulesets")
),
bot_id_enabled=security.get("botIdEnabled"),
)
logger.info(f"Project - Found {len(self.projects)} project(s)")
@@ -171,8 +160,4 @@ class VercelProject(BaseModel):
git_fork_protection: bool = True
git_repository: Optional[dict] = None
secure_compute: Optional[dict] = None
firewall_enabled: Optional[bool] = None
firewall_config_version: Optional[str] = None
managed_rules: Optional[dict] = None
bot_id_enabled: Optional[bool] = None
environment_variables: list[VercelEnvironmentVariable] = Field(default_factory=list)

View File

@@ -26,7 +26,10 @@ class Security(VercelService):
def _fetch_firewall_config(self, project):
"""Fetch WAF/Firewall config for a single project."""
try:
data = self._read_firewall_config(project)
data = self._get(
"/v1/security/firewall/config",
params={"projectId": project.id},
)
if data is None:
# 403 — plan limitation, store with managed_rulesets=None
@@ -41,60 +44,39 @@ class Security(VercelService):
)
return
fw = self._normalize_firewall_config(data)
# Parse firewall config
fw = data.get("firewallConfig", data) if isinstance(data, dict) else {}
if not fw:
fallback_firewall_enabled = self._fallback_firewall_enabled(project)
self.firewall_configs[project.id] = VercelFirewallConfig(
project_id=project.id,
project_name=project.name,
team_id=project.team_id,
firewall_enabled=(
fallback_firewall_enabled
if fallback_firewall_enabled is not None
else False
),
managed_rulesets=self._fallback_managed_rulesets(project),
name=project.name,
id=project.id,
)
return
rules = [
rule for rule in (fw.get("rules", []) or []) if self._is_active(rule)
]
managed = self._active_managed_rulesets(
fw.get("managedRules", fw.get("managedRulesets", fw.get("crs")))
)
# Determine if firewall is enabled
rules = fw.get("rules", []) or []
managed = fw.get("managedRules", fw.get("managedRulesets"))
custom_rules = []
ip_blocking = list(fw.get("ips", []) or [])
ip_blocking = []
rate_limiting = []
for rule in rules:
mitigate_action = self._mitigate_action(rule)
rule_action = rule.get("action", {})
action_type = (
rule_action.get("type", "")
if isinstance(rule_action, dict)
else str(rule_action)
)
if self._is_rate_limiting_rule(rule, mitigate_action):
if action_type == "rate_limit" or rule.get("rateLimit"):
rate_limiting.append(rule)
elif self._is_ip_rule(rule):
elif action_type in ("deny", "block") and self._is_ip_rule(rule):
ip_blocking.append(rule)
else:
custom_rules.append(rule)
firewall_enabled = fw.get("firewallEnabled")
if firewall_enabled is None:
firewall_enabled = self._fallback_firewall_enabled(project)
if firewall_enabled is None:
firewall_enabled = bool(rules) or bool(ip_blocking) or bool(managed)
if not managed:
managed = self._fallback_managed_rulesets(project)
firewall_enabled = bool(rules) or bool(managed)
self.firewall_configs[project.id] = VercelFirewallConfig(
project_id=project.id,
project_name=project.name,
team_id=project.team_id,
firewall_enabled=firewall_enabled,
managed_rulesets=managed,
managed_rulesets=managed if managed is not None else {},
custom_rules=custom_rules,
ip_blocking_rules=ip_blocking,
rate_limiting_rules=rate_limiting,
@@ -113,117 +95,6 @@ class Security(VercelService):
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _read_firewall_config(self, project):
"""Read the deployed firewall config via the documented endpoint.
See: https://vercel.com/docs/rest-api/security/read-firewall-configuration
"""
params = self._firewall_params(project)
config_version = getattr(project, "firewall_config_version", None)
endpoints = []
if config_version:
endpoints.append(f"/v1/security/firewall/config/{config_version}")
endpoints.append("/v1/security/firewall/config/active")
last_error = None
for endpoint in endpoints:
try:
return self._get(endpoint, params=params)
except Exception as error:
last_error = error
logger.warning(
f"Security - Firewall config read failed for project "
f"{project.id} (team={getattr(project, 'team_id', None)}) "
f"on {endpoint} with params={params}: "
f"{error.__class__.__name__}: {error}"
)
if last_error is not None:
logger.debug(
f"Security - Falling back to firewall config wrapper for "
f"{project.id} after {last_error.__class__.__name__}: {last_error}"
)
return self._get("/v1/security/firewall/config", params=params)
@staticmethod
def _firewall_params(project) -> dict:
"""Build firewall request params, preserving team scope for team projects."""
params = {"projectId": project.id}
team_id = getattr(project, "team_id", None)
if isinstance(team_id, str) and team_id:
params["teamId"] = team_id
return params
@staticmethod
def _normalize_firewall_config(data: dict) -> dict:
"""Normalize firewall responses across Vercel endpoint variants."""
if not isinstance(data, dict):
return {}
if "firewallConfig" in data and isinstance(data["firewallConfig"], dict):
return data["firewallConfig"]
if any(key in data for key in ("active", "draft", "versions")):
return data.get("active") or {}
return data
@staticmethod
def _active_managed_rulesets(managed_rules: dict | None) -> dict:
"""Return only active managed rulesets."""
if not isinstance(managed_rules, dict):
return {}
return {
ruleset: config
for ruleset, config in managed_rules.items()
if not isinstance(config, dict) or config.get("active", False)
}
@classmethod
def _fallback_managed_rulesets(cls, project) -> dict:
"""Return active managed rulesets from project metadata."""
return cls._active_managed_rulesets(getattr(project, "managed_rules", None))
@staticmethod
def _fallback_firewall_enabled(project) -> bool | None:
"""Return firewall enabled state from project metadata when available."""
return getattr(project, "firewall_enabled", None)
@staticmethod
def _mitigate_action(rule: dict) -> dict:
"""Extract the nested Vercel mitigation action payload for a rule."""
action = rule.get("action", {})
if not isinstance(action, dict):
return {}
mitigate = action.get("mitigate")
return mitigate if isinstance(mitigate, dict) else action
@staticmethod
def _is_active(rule: dict) -> bool:
"""Treat missing active flags as enabled for backwards compatibility."""
return rule.get("active", True) is not False
@classmethod
def _is_rate_limiting_rule(
cls, rule: dict, mitigate_action: dict | None = None
) -> bool:
"""Check if a firewall rule enforces rate limiting."""
if rule.get("rateLimit"):
return True
mitigate = (
mitigate_action
if isinstance(mitigate_action, dict)
else cls._mitigate_action(rule)
)
return bool(mitigate.get("rateLimit")) or mitigate.get("action") == "rate_limit"
@staticmethod
def _is_ip_rule(rule: dict) -> bool:
"""Check if a rule is an IP blocking rule based on conditions."""

View File

@@ -1,45 +0,0 @@
from unittest import mock
from prowler.providers.vercel.services.project.project_service import Project
from tests.providers.vercel.vercel_fixtures import (
PROJECT_ID,
PROJECT_NAME,
TEAM_ID,
set_mocked_vercel_provider,
)
class TestProjectService:
def test_list_projects_parses_security_metadata(self):
service = Project.__new__(Project)
service.provider = set_mocked_vercel_provider()
service.projects = {}
service._paginate = mock.MagicMock(
return_value=[
{
"id": PROJECT_ID,
"name": PROJECT_NAME,
"accountId": TEAM_ID,
"security": {
"firewallEnabled": True,
"firewallConfigVersion": 42,
"managedRules": {
"owasp": {"active": True, "action": "log"},
"ai_bots": {"active": False, "action": "deny"},
},
"botIdEnabled": True,
},
}
]
)
service._list_projects()
project = service.projects[PROJECT_ID]
assert project.firewall_enabled is True
assert project.firewall_config_version == "42"
assert project.managed_rules == {
"owasp": {"active": True, "action": "log"},
"ai_bots": {"active": False, "action": "deny"},
}
assert project.bot_id_enabled is True

View File

@@ -1,199 +0,0 @@
from unittest import mock
from prowler.providers.vercel.services.project.project_service import VercelProject
from prowler.providers.vercel.services.security.security_service import Security
from tests.providers.vercel.vercel_fixtures import PROJECT_ID, PROJECT_NAME, TEAM_ID
class TestSecurityService:
def test_fetch_firewall_config_reads_active_version_and_normalizes_response(self):
project = VercelProject(id=PROJECT_ID, name=PROJECT_NAME, team_id=TEAM_ID)
service = Security.__new__(Security)
service.firewall_configs = {}
service._get = mock.MagicMock(
return_value={
"active": {
"firewallEnabled": True,
"managedRules": {
"owasp": {"active": True, "action": "deny"},
"ai_bots": {"active": False, "action": "deny"},
},
"rules": [
{
"id": "rule-custom",
"name": "Block admin access",
"active": True,
"conditionGroup": [
{
"conditions": [
{
"type": "path",
"op": "pre",
"value": "/admin",
}
]
}
],
"action": {
"mitigate": {
"action": "deny",
}
},
},
{
"id": "rule-rate-limit",
"name": "Rate limit login",
"active": True,
"conditionGroup": [
{
"conditions": [
{
"type": "path",
"op": "eq",
"value": "/login",
}
]
}
],
"action": {
"mitigate": {
"action": "deny",
"rateLimit": {
"algo": "fixed_window",
"window": 60,
"limit": 10,
},
}
},
},
],
"ips": [
{
"id": "ip-rule",
"ip": "203.0.113.7",
"action": "deny",
}
],
},
"draft": None,
"versions": [1],
}
)
service._fetch_firewall_config(project)
service._get.assert_called_once_with(
"/v1/security/firewall/config/active",
params={"projectId": PROJECT_ID, "teamId": TEAM_ID},
)
config = service.firewall_configs[PROJECT_ID]
assert config.firewall_enabled is True
assert config.managed_rulesets == {"owasp": {"active": True, "action": "deny"}}
assert [rule["id"] for rule in config.custom_rules] == ["rule-custom"]
assert [rule["id"] for rule in config.rate_limiting_rules] == [
"rule-rate-limit"
]
assert [rule["id"] for rule in config.ip_blocking_rules] == ["ip-rule"]
def test_fetch_firewall_config_parses_crs_managed_rulesets(self):
project = VercelProject(
id=PROJECT_ID,
name=PROJECT_NAME,
team_id=TEAM_ID,
firewall_config_version="1",
)
service = Security.__new__(Security)
service.firewall_configs = {}
service._get = mock.MagicMock(
return_value={
"id": "waf_test",
"version": 1,
"firewallEnabled": True,
"crs": {
"gen": {"active": True, "action": "log"},
"xss": {"active": True, "action": "deny"},
"php": {"active": False, "action": "log"},
},
"rules": [],
"ips": [],
}
)
service._fetch_firewall_config(project)
config = service.firewall_configs[PROJECT_ID]
assert config.firewall_enabled is True
assert config.managed_rulesets == {
"gen": {"active": True, "action": "log"},
"xss": {"active": True, "action": "deny"},
}
def test_fetch_firewall_config_falls_back_to_wrapper_when_active_missing(self):
project = VercelProject(id=PROJECT_ID, name=PROJECT_NAME, team_id=TEAM_ID)
service = Security.__new__(Security)
service.firewall_configs = {}
service._get = mock.MagicMock(
side_effect=[
Exception("404 active config not found"),
{"active": None, "draft": None, "versions": []},
]
)
service._fetch_firewall_config(project)
assert service._get.call_args_list == [
mock.call(
"/v1/security/firewall/config/active",
params={"projectId": PROJECT_ID, "teamId": TEAM_ID},
),
mock.call(
"/v1/security/firewall/config",
params={"projectId": PROJECT_ID, "teamId": TEAM_ID},
),
]
config = service.firewall_configs[PROJECT_ID]
assert config.firewall_enabled is False
assert config.managed_rulesets == {}
assert config.custom_rules == []
assert config.rate_limiting_rules == []
assert config.ip_blocking_rules == []
def test_fetch_firewall_config_uses_project_security_metadata_when_config_empty(
self,
):
project = VercelProject(
id=PROJECT_ID,
name=PROJECT_NAME,
team_id=TEAM_ID,
firewall_enabled=True,
firewall_config_version="42",
managed_rules={
"owasp": {"active": True, "action": "log"},
"ai_bots": {"active": False, "action": "deny"},
},
)
service = Security.__new__(Security)
service.firewall_configs = {}
service._get = mock.MagicMock(
return_value={"active": None, "draft": None, "versions": []}
)
service._fetch_firewall_config(project)
service._get.assert_called_once_with(
"/v1/security/firewall/config/42",
params={"projectId": PROJECT_ID, "teamId": TEAM_ID},
)
config = service.firewall_configs[PROJECT_ID]
assert config.firewall_enabled is True
assert config.managed_rulesets == {"owasp": {"active": True, "action": "log"}}
assert config.custom_rules == []
assert config.rate_limiting_rules == []
assert config.ip_blocking_rules == []

View File

@@ -7,22 +7,11 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🚀 Added
- Resources side drawer with redesigned detail panel [(#10673)](https://github.com/prowler-cloud/prowler/pull/10673)
- Syntax highlighting for remediation code blocks in finding groups drawer with provider-aware auto-detection (Shell, HCL, YAML, Bicep) [(#10698)](https://github.com/prowler-cloud/prowler/pull/10698)
### 🔄 Changed
- Attack Paths scan selection: contextual button labels based on graph availability, tooltips on disabled actions, green dot indicator for selectable scans, and a warning banner when viewing data from a previous scan cycle [(#10685)](https://github.com/prowler-cloud/prowler/pull/10685)
### 🔄 Changed
- Remove legacy finding detail sheet, row-details wrapper, and resource detail panel; unify findings and resources around new side drawers [(#10692)](https://github.com/prowler-cloud/prowler/pull/10692)
- Attack Paths "View Finding" now opens the finding drawer inline over the graph instead of navigating to `/findings` in a new tab, preserving graph zoom, selection, and filter state
### 🐞 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)
- Provider wizard modal moved to a stable page-level host so the providers table refreshes after link, authenticate, and connection check without closing the modal [(#10675)](https://github.com/prowler-cloud/prowler/pull/10675)
---
@@ -51,8 +40,10 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🐞 Fixed
- Preserve query parameters in callbackUrl during invitation flow [(#10571)](https://github.com/prowler-cloud/prowler/pull/10571)
- Attack Paths scan auto-refresh now correctly detects "available" (queued) scans as active [(#10476)](https://github.com/prowler-cloud/prowler/pull/10476)
- Attack Paths empty state not showing when no scans exist [(#10469)](https://github.com/prowler-cloud/prowler/pull/10469)
- Deleting the active organization now switches to the target org before deleting, preventing JWT rejection from the backend [(#10491)](https://github.com/prowler-cloud/prowler/pull/10491)
- Clear Filters now resets all filters including muted findings and auto-applies, Clear all in pills only removes pill-visible sub-filters, and the discard icon is now an Undo text button [(#10446)](https://github.com/prowler-cloud/prowler/pull/10446)
- Send to Jira modal now dynamically fetches and displays available issue types per project instead of hardcoding `"Task"`, fixing failures on non-English Jira instances [(#10534)](https://github.com/prowler-cloud/prowler/pull/10534)
- Exclude service filter from finding group resources endpoint to prevent empty results when a service filter is active [(#10652)](https://github.com/prowler-cloud/prowler/pull/10652)
---

View File

@@ -70,7 +70,7 @@ describe("getFindingGroups — default sort for muted and non-muted rows", () =>
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("sort")).toBe(
"-status,-severity,-new_fail_count,-changed_fail_count,-fail_count,-last_seen_at",
"-status,-new_fail_count,-changed_fail_count,-severity,-fail_count,-last_seen_at",
);
});
@@ -84,7 +84,7 @@ describe("getFindingGroups — default sort for muted and non-muted rows", () =>
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("sort")).toBe(
"-status,-severity,-new_fail_count,-changed_fail_count,-new_fail_muted_count,-changed_fail_muted_count,-fail_count,-fail_muted_count,-last_seen_at",
"-status,-new_fail_count,-changed_fail_count,-severity,-new_fail_muted_count,-changed_fail_muted_count,-fail_count,-fail_muted_count,-last_seen_at",
);
});
});
@@ -106,7 +106,7 @@ describe("getLatestFindingGroups — default sort for muted and non-muted rows",
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("sort")).toBe(
"-status,-severity,-new_fail_count,-changed_fail_count,-fail_count,-last_seen_at",
"-status,-new_fail_count,-changed_fail_count,-severity,-fail_count,-last_seen_at",
);
});
@@ -120,7 +120,7 @@ describe("getLatestFindingGroups — default sort for muted and non-muted rows",
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("sort")).toBe(
"-status,-severity,-new_fail_count,-changed_fail_count,-new_fail_muted_count,-changed_fail_muted_count,-fail_count,-fail_muted_count,-last_seen_at",
"-status,-new_fail_count,-changed_fail_count,-severity,-new_fail_muted_count,-changed_fail_muted_count,-fail_count,-fail_muted_count,-last_seen_at",
);
});
});
@@ -262,7 +262,7 @@ describe("getFindingGroupResources — Blocker 1: FAIL-first sort", () => {
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("sort")).toBe(
"-status,-severity,-delta,-last_seen_at",
"-status,-delta,-severity,-last_seen_at",
);
});
@@ -300,7 +300,7 @@ describe("getLatestFindingGroupResources — Blocker 1: FAIL-first sort", () =>
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("sort")).toBe(
"-status,-severity,-delta,-last_seen_at",
"-status,-delta,-severity,-last_seen_at",
);
});
@@ -344,7 +344,7 @@ describe("getFindingGroupResources — triangulation: params coexist", () => {
expect(url.searchParams.get("page[number]")).toBe("2");
expect(url.searchParams.get("page[size]")).toBe("50");
expect(url.searchParams.get("sort")).toBe(
"-status,-severity,-delta,-last_seen_at",
"-status,-delta,-severity,-last_seen_at",
);
expect(url.searchParams.get("filter[status]")).toBeNull();
});
@@ -372,7 +372,7 @@ describe("getLatestFindingGroupResources — triangulation: params coexist", ()
expect(url.searchParams.get("page[number]")).toBe("3");
expect(url.searchParams.get("page[size]")).toBe("20");
expect(url.searchParams.get("sort")).toBe(
"-status,-severity,-delta,-last_seen_at",
"-status,-delta,-severity,-last_seen_at",
);
expect(url.searchParams.get("filter[status]")).toBeNull();
});
@@ -443,7 +443,7 @@ describe("getFindingGroupResources — caller filters are preserved", () => {
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("sort")).toBe(
"-status,-severity,-delta,-last_seen_at",
"-status,-delta,-severity,-last_seen_at",
);
expect(url.searchParams.get("filter[name__icontains]")).toBe("bucket-prod");
expect(url.searchParams.get("filter[severity__in]")).toBe("high");
@@ -533,7 +533,7 @@ describe("getLatestFindingGroupResources — caller filters are preserved", () =
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("sort")).toBe(
"-status,-severity,-delta,-last_seen_at",
"-status,-delta,-severity,-last_seen_at",
);
expect(url.searchParams.get("filter[name__icontains]")).toBe(
"instance-prod",

View File

@@ -83,13 +83,13 @@ function normalizeFindingGroupResourceFilters(
}
const DEFAULT_FINDING_GROUPS_SORT =
"-status,-severity,-new_fail_count,-changed_fail_count,-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,-severity,-new_fail_count,-changed_fail_count,-new_fail_muted_count,-changed_fail_muted_count,-fail_count,-fail_muted_count,-last_seen_at";
"-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,-severity,-delta,-last_seen_at";
"-status,-delta,-severity,-last_seen_at";
interface FetchFindingGroupsParams {
page?: number;

View File

@@ -262,6 +262,7 @@ export const getLatestFindingsByResourceUid = async ({
);
url.searchParams.append("filter[resource_uid]", resourceUid);
url.searchParams.append("filter[status]", "FAIL");
url.searchParams.append("filter[muted]", "include");
url.searchParams.append("sort", "-severity,-updated_at");
if (page) url.searchParams.append("page[number]", page.toString());

View File

@@ -1,135 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
fetchMock,
getAuthHeadersMock,
getFormValueMock,
handleApiErrorMock,
handleApiResponseMock,
} = vi.hoisted(() => ({
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
getFormValueMock: vi.fn(),
handleApiErrorMock: vi.fn(),
handleApiResponseMock: vi.fn(),
}));
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: getAuthHeadersMock,
getFormValue: getFormValueMock,
wait: vi.fn(),
}));
vi.mock("@/lib/provider-credentials/build-crendentials", () => ({
buildSecretConfig: vi.fn(() => ({
secretType: "access-secret-key",
secret: { key: "value" },
})),
}));
vi.mock("@/lib/provider-filters", () => ({
appendSanitizedProviderInFilters: vi.fn(),
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiError: handleApiErrorMock,
handleApiResponse: handleApiResponseMock,
}));
import {
addCredentialsProvider,
addProvider,
checkConnectionProvider,
updateCredentialsProvider,
} from "./providers";
describe("providers actions", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
getFormValueMock.mockImplementation((formData: FormData, field: string) =>
formData.get(field),
);
handleApiErrorMock.mockReturnValue({ error: "Unexpected error" });
handleApiResponseMock.mockResolvedValue({ data: { id: "secret-1" } });
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ data: { id: "secret-1" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
});
it("should revalidate providers after linking a cloud provider", async () => {
// Given
const formData = new FormData();
formData.set("providerType", "aws");
formData.set("providerUid", "111111111111");
// When
await addProvider(formData);
// Then
expect(handleApiResponseMock).toHaveBeenCalledWith(
expect.any(Response),
"/providers",
);
});
it("should revalidate providers after adding credentials in the wizard", async () => {
// Given
const formData = new FormData();
formData.set("providerId", "provider-1");
formData.set("providerType", "aws");
// When
await addCredentialsProvider(formData);
// Then
expect(handleApiResponseMock).toHaveBeenCalledWith(
expect.any(Response),
"/providers",
);
});
it("should revalidate providers after updating credentials in the wizard", async () => {
// Given
const formData = new FormData();
formData.set("providerId", "provider-1");
formData.set("providerType", "oraclecloud");
// When
await updateCredentialsProvider("secret-1", formData);
// Then
expect(handleApiResponseMock).toHaveBeenCalledWith(
expect.any(Response),
"/providers",
);
});
it("should revalidate providers when checking connection from the wizard", async () => {
// Given
const formData = new FormData();
formData.set("providerId", "provider-1");
// When
await checkConnectionProvider(formData);
// Then
expect(handleApiResponseMock).toHaveBeenCalledWith(
expect.any(Response),
"/providers",
);
});
});

View File

@@ -3,7 +3,6 @@ export {
getLatestResources,
getMetadataInfo,
getResourceById,
getResourceDrawerData,
getResourceEvents,
getResources,
} from "./resources";

View File

@@ -2,12 +2,9 @@
import { redirect } from "next/navigation";
import { getLatestFindings } from "@/actions/findings";
import { listOrganizationsSafe } from "@/actions/organizations/organizations";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
import { handleApiResponse } from "@/lib/server-actions-helper";
import { OrganizationResource } from "@/types/organizations";
export const getResources = async ({
page = 1,
@@ -258,57 +255,3 @@ export const getResourceById = async (
return undefined;
}
};
export const getResourceDrawerData = async ({
resourceId,
resourceUid,
providerId,
providerType,
page = 1,
pageSize = 10,
query = "",
}: {
resourceId: string;
resourceUid: string;
providerId: string;
providerType: string;
page?: number;
pageSize?: number;
query?: string;
}) => {
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const [resourceData, findingsResponse, organizationsResponse] =
await Promise.all([
getResourceById(resourceId, { fields: ["tags"] }),
getLatestFindings({
page,
pageSize,
query,
sort: "severity,-inserted_at",
filters: {
"filter[resource_uid]": resourceUid,
"filter[status]": "FAIL",
},
}),
isCloudEnv && providerType === "aws"
? listOrganizationsSafe()
: Promise.resolve({ data: [] }),
]);
const providerOrg =
providerType === "aws"
? (organizationsResponse.data.find((organization: OrganizationResource) =>
organization.relationships?.providers?.data?.some(
(provider: { id: string }) => provider.id === providerId,
),
) ?? null)
: null;
return {
findings: findingsResponse?.data ?? [],
findingsMeta: findingsResponse?.meta ?? null,
providerOrg,
resourceTags: resourceData?.data?.attributes.tags ?? {},
};
};

View File

@@ -1,16 +0,0 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("findings view overview SSR", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const filePath = path.join(currentDir, "findings-view.ssr.tsx");
const source = readFileSync(filePath, "utf8");
it("uses the non-legacy latest findings columns", () => {
expect(source).toContain("ColumnLatestFindings");
expect(source).not.toContain("ColumnNewFindingsToDate");
});
});

View File

@@ -3,7 +3,7 @@
import { getLatestFindings } from "@/actions/findings/findings";
import { LighthouseBanner } from "@/components/lighthouse/banner";
import { LinkToFindings } from "@/components/overview";
import { ColumnLatestFindings } from "@/components/overview/new-findings-table/table";
import { ColumnNewFindingsToDate } from "@/components/overview/new-findings-table/table/column-new-findings-to-date";
import { DataTable } from "@/components/ui/table";
import { createDict } from "@/lib/helper";
import { FindingProps, SearchParamsProps } from "@/types";
@@ -73,7 +73,7 @@ export async function FindingsViewSSR({ searchParams }: FindingsViewSSRProps) {
<DataTable
key={`dashboard-findings-${Date.now()}`}
columns={ColumnLatestFindings}
columns={ColumnNewFindingsToDate}
data={(expandedResponse?.data || []) as FindingProps[]}
/>
</div>

View File

@@ -1,93 +0,0 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import type { GraphNode } from "@/types/attack-paths";
import { NodeDetailPanel } from "./node-detail-panel";
vi.mock("@/components/ui/sheet/sheet", () => ({
Sheet: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SheetContent: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
SheetDescription: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
SheetHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SheetTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("./node-overview", () => ({
NodeOverview: () => <div>Node overview</div>,
}));
vi.mock("./node-findings", () => ({
NodeFindings: () => <div>Node findings</div>,
}));
vi.mock("./node-resources", () => ({
NodeResources: () => <div>Node resources</div>,
}));
const findingNode: GraphNode = {
id: "graph-node-id",
labels: ["ProwlerFinding"],
properties: {
id: "finding-123",
check_title: "Open S3 bucket",
name: "Open S3 bucket",
},
};
const resourceNode: GraphNode = {
id: "resource-node-id",
labels: ["S3Bucket"],
properties: {
id: "bucket-123",
name: "bucket-123",
},
};
describe("NodeDetailPanel", () => {
it("renders the view finding button only for finding nodes", () => {
const { rerender } = render(<NodeDetailPanel node={findingNode} />);
expect(
screen.getByRole("button", { name: /view finding finding-123/i }),
).toBeInTheDocument();
rerender(<NodeDetailPanel node={resourceNode} />);
expect(
screen.queryByRole("button", { name: /view finding/i }),
).not.toBeInTheDocument();
});
it("calls onViewFinding with the node finding id", async () => {
const user = userEvent.setup();
const onViewFinding = vi.fn();
render(
<NodeDetailPanel node={findingNode} onViewFinding={onViewFinding} />,
);
await user.click(
screen.getByRole("button", { name: /view finding finding-123/i }),
);
expect(onViewFinding).toHaveBeenCalledWith("finding-123");
});
it("disables the button and shows the spinner while loading", () => {
render(<NodeDetailPanel node={findingNode} viewFindingLoading />);
const button = screen.getByRole("button", {
name: /view finding finding-123/i,
});
expect(button).toBeDisabled();
expect(screen.getByLabelText("Loading")).toHaveClass("size-4");
});
});

View File

@@ -1,7 +1,6 @@
"use client";
import { Button, Card, CardContent } from "@/components/shadcn";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import {
Sheet,
SheetContent,
@@ -19,8 +18,6 @@ interface NodeDetailPanelProps {
node: GraphNode | null;
allNodes?: GraphNode[];
onClose?: () => void;
onViewFinding?: (findingId: string) => void;
viewFindingLoading?: boolean;
}
/**
@@ -29,13 +26,9 @@ interface NodeDetailPanelProps {
export const NodeDetailContent = ({
node,
allNodes = [],
onViewFinding,
viewFindingLoading = false,
}: {
node: GraphNode;
allNodes?: GraphNode[];
onViewFinding?: (findingId: string) => void;
viewFindingLoading?: boolean;
}) => {
const isProwlerFinding = node?.labels.some((label) =>
label.toLowerCase().includes("finding"),
@@ -63,12 +56,7 @@ export const NodeDetailContent = ({
<div className="text-text-neutral-secondary dark:text-text-neutral-secondary text-xs">
Findings connected to this node
</div>
<NodeFindings
node={node}
allNodes={allNodes}
onViewFinding={onViewFinding}
viewFindingLoading={viewFindingLoading}
/>
<NodeFindings node={node} allNodes={allNodes} />
</CardContent>
</Card>
)}
@@ -100,15 +88,12 @@ export const NodeDetailPanel = ({
node,
allNodes = [],
onClose,
onViewFinding,
viewFindingLoading = false,
}: NodeDetailPanelProps) => {
const isOpen = node !== null;
const isProwlerFinding = node?.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
const findingId = node ? String(node.properties?.id || node.id) : "";
return (
<Sheet open={isOpen} onOpenChange={(open) => !open && onClose?.()}>
@@ -122,19 +107,15 @@ export const NodeDetailPanel = ({
</SheetDescription>
</div>
{node && isProwlerFinding && (
<Button
variant="default"
size="sm"
className="mt-1"
onClick={() => onViewFinding?.(findingId)}
disabled={viewFindingLoading}
aria-label={`View finding ${findingId}`}
>
{viewFindingLoading ? (
<Spinner className="size-4" />
) : (
"View Finding →"
)}
<Button asChild variant="default" size="sm" className="mt-1">
<a
href={`/findings?id=${String(node.properties?.id || node.id)}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`View finding ${String(node.properties?.id || node.id)}`}
>
View Finding
</a>
</Button>
)}
</div>
@@ -142,12 +123,7 @@ export const NodeDetailPanel = ({
{node && (
<div className="pt-6">
<NodeDetailContent
node={node}
allNodes={allNodes}
onViewFinding={onViewFinding}
viewFindingLoading={viewFindingLoading}
/>
<NodeDetailContent node={node} allNodes={allNodes} />
</div>
)}
</SheetContent>

View File

@@ -1,7 +1,5 @@
"use client";
import { Button } from "@/components/shadcn";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { SeverityBadge } from "@/components/ui/table/severity-badge";
import type { GraphNode } from "@/types/attack-paths";
@@ -18,20 +16,13 @@ type Severity = (typeof SEVERITY_LEVELS)[keyof typeof SEVERITY_LEVELS];
interface NodeFindingsProps {
node: GraphNode;
allNodes?: GraphNode[];
onViewFinding?: (findingId: string) => void;
viewFindingLoading?: boolean;
}
/**
* Node findings section showing related findings for the selected node
* Displays findings that are connected to the node via HAS_FINDING edges
*/
export const NodeFindings = ({
node,
allNodes = [],
onViewFinding,
viewFindingLoading = false,
}: NodeFindingsProps) => {
export const NodeFindings = ({ node, allNodes = [] }: NodeFindingsProps) => {
// Get finding IDs from the node's findings array (populated by adapter)
const findingIds = node.findings || [];
@@ -88,20 +79,15 @@ export const NodeFindings = ({
ID: {findingId}
</p>
</div>
<Button
variant="link"
size="sm"
onClick={() => onViewFinding?.(findingId)}
disabled={viewFindingLoading}
<a
href={`/findings?id=${findingId}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`View full finding for ${findingName}`}
className="text-text-info dark:text-text-info h-auto shrink-0 p-0 text-xs font-medium hover:underline"
>
{viewFindingLoading ? (
<Spinner className="size-4" />
) : (
"View Full Finding →"
)}
</Button>
View Full Finding
</a>
</div>
{finding.properties?.description && (
<div className="text-text-neutral-secondary dark:text-text-neutral-secondary mt-2 text-xs">

View File

@@ -1,8 +1,8 @@
"use client";
import Link from "next/link";
import { Badge } from "@/components/shadcn/badge/badge";
import { Button } from "@/components/shadcn/button/button";
import { Spinner } from "@/components/shadcn/spinner/spinner";
interface Finding {
id: string;
@@ -13,18 +13,12 @@ interface Finding {
interface NodeRemediationProps {
findings: Finding[];
onViewFinding?: (findingId: string) => void;
viewFindingLoading?: boolean;
}
/**
* Node remediation section showing related Prowler findings
*/
export const NodeRemediation = ({
findings,
onViewFinding,
viewFindingLoading = false,
}: NodeRemediationProps) => {
export const NodeRemediation = ({ findings }: NodeRemediationProps) => {
const getSeverityVariant = (severity: string) => {
switch (severity) {
case "critical":
@@ -72,20 +66,15 @@ export const NodeRemediation = ({
</div>
</div>
<div className="mt-2">
<Button
variant="link"
size="sm"
onClick={() => onViewFinding?.(finding.id)}
disabled={viewFindingLoading}
<Link
href={`/findings?id=${finding.id}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`View full finding for ${finding.title}`}
className="text-text-info dark:text-text-info h-auto p-0 text-sm transition-all hover:opacity-80 dark:hover:opacity-80"
className="text-text-info dark:text-text-info text-sm transition-all hover:opacity-80 dark:hover:opacity-80"
>
{viewFindingLoading ? (
<Spinner className="size-4" />
) : (
"View Full Finding →"
)}
</Button>
View Full Finding
</Link>
</div>
</div>
))}

View File

@@ -24,19 +24,6 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => navigationState.searchParams,
}));
vi.mock("@/components/shadcn/tooltip", () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({
children,
}: {
children: ReactNode;
asChild?: boolean;
}) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => (
<span data-testid="tooltip-content">{children}</span>
),
}));
vi.mock("@/components/ui/entities/entity-info", () => ({
EntityInfo: ({
entityAlias,
@@ -216,93 +203,4 @@ describe("ScanListTable", () => {
expect(button).toBeDisabled();
expect(button).toHaveTextContent("Failed");
});
it("shows 'Scheduled' label for a scheduled scan without graph data", () => {
const scheduledScan: AttackPathScan = {
...createScan(1),
attributes: {
...createScan(1).attributes,
state: "scheduled",
progress: 0,
graph_data_ready: false,
completed_at: null,
duration: null,
},
};
render(<ScanListTable scans={[scheduledScan]} />);
const button = screen.getByRole("button", { name: "Select scan" });
expect(button).toBeDisabled();
expect(button).toHaveTextContent("Scheduled");
});
it("shows 'Running...' label for an executing scan without graph data", () => {
const executingScan: AttackPathScan = {
...createScan(1),
attributes: {
...createScan(1).attributes,
state: "executing",
progress: 45,
graph_data_ready: false,
completed_at: null,
duration: null,
},
};
render(<ScanListTable scans={[executingScan]} />);
const button = screen.getByRole("button", { name: "Select scan" });
expect(button).toBeDisabled();
expect(button).toHaveTextContent("Running...");
});
it("enables Select for a scheduled scan when graph data is ready from a previous cycle", async () => {
const user = userEvent.setup();
const scheduledWithGraph: AttackPathScan = {
...createScan(1),
attributes: {
...createScan(1).attributes,
state: "scheduled",
progress: 0,
graph_data_ready: true,
},
};
render(<ScanListTable scans={[scheduledWithGraph]} />);
const button = screen.getByRole("button", { name: "Select scan" });
expect(button).toBeEnabled();
expect(button).toHaveTextContent("Select");
await user.click(button);
expect(pushMock).toHaveBeenCalledWith(
"/attack-paths?scanPage=1&scanPageSize=5&scanId=scan-1",
);
});
it("shows a green dot next to the account name when graph data is ready", () => {
render(<ScanListTable scans={[createScan(1)]} />);
const dot = screen.getByLabelText("Graph data available");
expect(dot).toBeInTheDocument();
expect(dot).toHaveClass("bg-bg-pass-primary");
});
it("does not show a green dot when graph data is not ready", () => {
const noGraphScan: AttackPathScan = {
...createScan(1),
attributes: {
...createScan(1).attributes,
graph_data_ready: false,
},
};
render(<ScanListTable scans={[noGraphScan]} />);
expect(
screen.queryByLabelText("Graph data available"),
).not.toBeInTheDocument();
});
});

View File

@@ -4,18 +4,12 @@ import { ColumnDef } from "@tanstack/react-table";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/shadcn/button/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { DateWithTime } from "@/components/ui/entities/date-with-time";
import { EntityInfo } from "@/components/ui/entities/entity-info";
import { DataTable, DataTableColumnHeader } from "@/components/ui/table";
import { formatDuration } from "@/lib/date-utils";
import { cn } from "@/lib/utils";
import type { MetaDataProps, ProviderType } from "@/types";
import type { AttackPathScan } from "@/types/attack-paths";
import type { AttackPathScan, ScanState } from "@/types/attack-paths";
import { SCAN_STATES } from "@/types/attack-paths";
import { ScanStatusBadge } from "./scan-status-badge";
@@ -26,6 +20,12 @@ interface ScanListTableProps {
const DEFAULT_PAGE_SIZE = 5;
const PAGE_SIZE_OPTIONS = [2, 5, 10, 15];
const WAITING_STATES: readonly ScanState[] = [
SCAN_STATES.SCHEDULED,
SCAN_STATES.AVAILABLE,
SCAN_STATES.EXECUTING,
];
const parsePageParam = (value: string | null, fallback: number) => {
if (!value) return fallback;
@@ -57,16 +57,8 @@ const getSelectButtonLabel = (
return "Select";
}
if (scan.attributes.state === SCAN_STATES.SCHEDULED) {
return "Scheduled";
}
if (scan.attributes.state === SCAN_STATES.AVAILABLE) {
return "Queued";
}
if (scan.attributes.state === SCAN_STATES.EXECUTING) {
return "Running...";
if (WAITING_STATES.includes(scan.attributes.state)) {
return "Waiting...";
}
if (scan.attributes.state === SCAN_STATES.FAILED) {
@@ -76,30 +68,6 @@ const getSelectButtonLabel = (
return "Select";
};
const getDisabledTooltip = (scan: AttackPathScan): string | null => {
if (scan.attributes.graph_data_ready) {
return null;
}
if (scan.attributes.state === SCAN_STATES.SCHEDULED) {
return "Graph will be available once this scan runs and completes.";
}
if (scan.attributes.state === SCAN_STATES.AVAILABLE) {
return "This scan is queued. Graph will be available once it completes.";
}
if (scan.attributes.state === SCAN_STATES.EXECUTING) {
return "Scan is running. Graph will be available once it completes.";
}
if (scan.attributes.state === SCAN_STATES.FAILED) {
return "This scan failed. No graph data is available.";
}
return null;
};
const getSelectedRowSelection = (
scans: AttackPathScan[],
selectedScanId: string | null,
@@ -140,26 +108,11 @@ const getColumns = ({
<DataTableColumnHeader column={column} title="Account" />
),
cell: ({ row }) => (
<div className="flex items-center gap-2">
<span
className={cn(
"inline-block size-2 shrink-0 rounded-full",
row.original.attributes.graph_data_ready
? "bg-bg-pass-primary"
: "bg-transparent",
)}
aria-label={
row.original.attributes.graph_data_ready
? "Graph data available"
: undefined
}
/>
<EntityInfo
cloudProvider={row.original.attributes.provider_type as ProviderType}
entityAlias={row.original.attributes.provider_alias}
entityId={row.original.attributes.provider_uid}
/>
</div>
<EntityInfo
cloudProvider={row.original.attributes.provider_type as ProviderType}
entityAlias={row.original.attributes.provider_alias}
entityId={row.original.attributes.provider_uid}
/>
),
enableSorting: false,
},
@@ -217,37 +170,21 @@ const getColumns = ({
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => {
const isDisabled = isSelectDisabled(row.original, selectedScanId);
const tooltip = getDisabledTooltip(row.original);
const button = (
<Button
type="button"
aria-label="Select scan"
disabled={isDisabled}
variant={isDisabled ? "secondary" : "default"}
onClick={() => onSelectScan(row.original.id)}
className="w-full max-w-24"
>
{getSelectButtonLabel(row.original, selectedScanId)}
</Button>
return (
<div className="flex justify-end">
<Button
type="button"
aria-label="Select scan"
disabled={isDisabled}
variant={isDisabled ? "secondary" : "default"}
onClick={() => onSelectScan(row.original.id)}
className="w-full max-w-24"
>
{getSelectButtonLabel(row.original, selectedScanId)}
</Button>
</div>
);
if (isDisabled && tooltip) {
return (
<div className="flex justify-end">
<Tooltip>
<TooltipTrigger asChild>
<span className="w-full max-w-24" tabIndex={0}>
{button}
</span>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
</div>
);
}
return <div className="flex justify-end">{button}</div>;
},
enableSorting: false,
},

View File

@@ -8,7 +8,6 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { cn } from "@/lib/utils";
import type { ScanState } from "@/types/attack-paths";
import { SCAN_STATES } from "@/types/attack-paths";
@@ -57,7 +56,7 @@ export const ScanStatusBadge = ({
const config = BADGE_CONFIG[status];
const graphDot = graphDataReady && config.showGraphDot && (
<span className="bg-bg-pass-primary inline-block size-2 rounded-full" />
<span className="inline-block size-2 rounded-full bg-green-500" />
);
const tooltipText = graphDataReady
@@ -71,9 +70,7 @@ export const ScanStatusBadge = ({
<Loader2
size={14}
className={
graphDataReady
? "text-text-success-primary animate-spin"
: "animate-spin"
graphDataReady ? "animate-spin text-green-500" : "animate-spin"
}
/>
) : (
@@ -88,7 +85,7 @@ export const ScanStatusBadge = ({
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge className={cn(config.className, "gap-2")}>
<Badge className={`${config.className} gap-2`}>
{icon}
<span>{label}</span>
</Badge>

View File

@@ -1,6 +1,6 @@
"use client";
import { ArrowLeft, Info, Maximize2, TriangleAlert, X } from "lucide-react";
import { ArrowLeft, Info, Maximize2, X } from "lucide-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef, useState } from "react";
@@ -14,8 +14,6 @@ import {
getAvailableQueries,
} from "@/actions/attack-paths";
import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter";
import { FindingDetailDrawer } from "@/components/findings/table";
import { useFindingDetails } from "@/components/resources/table/use-finding-details";
import { AutoRefresh } from "@/components/scans";
import {
Alert,
@@ -32,7 +30,6 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/shadcn/dialog";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { useToast } from "@/components/ui";
import type {
AttackPathQuery,
@@ -40,7 +37,7 @@ import type {
AttackPathScan,
GraphNode,
} from "@/types/attack-paths";
import { ATTACK_PATH_QUERY_IDS, SCAN_STATES } from "@/types/attack-paths";
import { ATTACK_PATH_QUERY_IDS } from "@/types/attack-paths";
import {
AttackPathGraph,
@@ -68,7 +65,6 @@ export default function AttackPathsPage() {
const searchParams = useSearchParams();
const scanId = searchParams.get("scanId");
const graphState = useGraphState();
const finding = useFindingDetails();
const { toast } = useToast();
const [scansLoading, setScansLoading] = useState(true);
@@ -124,13 +120,6 @@ export default function AttackPathsPage() {
scan.attributes.state === "scheduled",
);
// Detect if the selected scan is showing data from a previous cycle
const selectedScan = scans.find((scan) => scan.id === scanId);
const isViewingPreviousCycleData =
selectedScan &&
selectedScan.attributes.graph_data_ready &&
selectedScan.attributes.state !== SCAN_STATES.COMPLETED;
// Callback to refresh scans (used by AutoRefresh component)
const refreshScans = async () => {
try {
@@ -315,14 +304,6 @@ export default function AttackPathsPage() {
graphState.selectNode(null);
};
const getFindingId = (node: GraphNode | null) =>
node ? String(node.properties?.id || node.id) : "";
const handleViewFinding = (findingId: string) => {
if (!findingId) return;
void finding.navigateToFinding(findingId);
};
const handleGraphExport = (svgElement: SVGSVGElement | null) => {
try {
if (svgElement) {
@@ -392,24 +373,6 @@ export default function AttackPathsPage() {
<ScanListTable scans={scans} />
</Suspense>
{/* Banner: viewing data from a previous scan cycle */}
{isViewingPreviousCycleData && (
<Alert
variant="default"
className="border-border-warning-secondary bg-bg-warning-secondary"
>
<TriangleAlert className="text-text-warning-primary size-4" />
<AlertTitle>Viewing data from a previous scan</AlertTitle>
<AlertDescription>
This scan is currently{" "}
{selectedScan.attributes.state === SCAN_STATES.EXECUTING
? `running (${selectedScan.attributes.progress}%)`
: selectedScan.attributes.state}
. The graph data shown is from the last completed cycle.
</AlertDescription>
</Alert>
)}
{/* Query Builder Section - shown only after selecting a scan */}
{scanId && (
<div className="minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col gap-4 overflow-auto border p-4">
@@ -696,20 +659,15 @@ export default function AttackPathsPage() {
{graphState.selectedNode.labels.some((label) =>
label.toLowerCase().includes("finding"),
) && (
<Button
variant="default"
size="sm"
onClick={() =>
handleViewFinding(getFindingId(graphState.selectedNode))
}
disabled={finding.findingDetailLoading}
aria-label={`View finding ${getFindingId(graphState.selectedNode)}`}
>
{finding.findingDetailLoading ? (
<Spinner className="size-4" />
) : (
"View Finding"
)}
<Button asChild variant="default" size="sm">
<a
href={`/findings?id=${String(graphState.selectedNode.properties?.id || graphState.selectedNode.id)}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`View finding ${String(graphState.selectedNode.properties?.id || graphState.selectedNode.id)}`}
>
View Finding
</a>
</Button>
)}
<Button
@@ -727,22 +685,9 @@ export default function AttackPathsPage() {
<NodeDetailContent
node={graphState.selectedNode}
allNodes={graphState.data.nodes}
onViewFinding={handleViewFinding}
viewFindingLoading={finding.findingDetailLoading}
/>
</div>
)}
{finding.findingDetails && (
<FindingDetailDrawer
key={finding.findingDetails.id}
finding={finding.findingDetails}
defaultOpen
onOpenChange={(open) => {
if (!open) finding.resetFindingDetails();
}}
/>
)}
</>
)}
</div>

View File

@@ -37,6 +37,9 @@ export default async function Findings({
// Check if the searchParams contain any date or scan filter
const hasDateOrScan = hasDateOrScanFilter(resolvedSearchParams);
// TODO: Re-implement deep link support (/findings?id=<uuid>) using the grouped view's resource detail drawer
// once the legacy FindingDetailsSheet is fully deprecated (still used by /resources and overview dashboard).
const [providersData, scansData] = await Promise.all([
getProviders({ pageSize: 50 }),
getScans({ pageSize: 50 }),

View File

@@ -1,6 +1,11 @@
import { Suspense } from "react";
import { ProvidersAccountsView } from "@/components/providers";
import {
AddProviderButton,
MutedFindingsConfigButton,
ProvidersAccountsTable,
ProvidersFilters,
} from "@/components/providers";
import { SkeletonTableProviders } from "@/components/providers/table";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { ContentLayout } from "@/components/ui";
@@ -51,6 +56,15 @@ export default async function Providers({
);
}
const ProvidersActions = () => {
return (
<div className="flex flex-wrap gap-4 md:justify-end">
<MutedFindingsConfigButton />
<AddProviderButton />
</div>
);
};
const ProvidersTableFallback = () => {
return (
<div className="flex flex-col gap-6">
@@ -106,12 +120,17 @@ const ProvidersAccountsContent = async ({
});
return (
<ProvidersAccountsView
isCloud={process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"}
filters={providersView.filters}
providers={providersView.providers}
metadata={providersView.metadata}
rows={providersView.rows}
/>
<div className="flex flex-col gap-6">
<ProvidersFilters
filters={providersView.filters}
providers={providersView.providers}
actions={<ProvidersActions />}
/>
<ProvidersAccountsTable
isCloud={process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"}
metadata={providersView.metadata}
rows={providersView.rows}
/>
</div>
);
};

View File

@@ -1,16 +0,0 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("client accordion content", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const filePath = path.join(currentDir, "client-accordion-content.tsx");
const source = readFileSync(filePath, "utf8");
it("uses the shared standalone finding columns instead of the legacy findings columns", () => {
expect(source).toContain("getStandaloneFindingColumns");
expect(source).not.toContain("getColumnFindings");
});
});

View File

@@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from "react";
import { getFindings } from "@/actions/findings/findings";
import {
getStandaloneFindingColumns,
getColumnFindings,
SkeletonTableFindings,
} from "@/components/findings/table";
import { Accordion } from "@/components/ui/accordion/Accordion";
@@ -33,7 +33,6 @@ export const ClientAccordionContent = ({
const searchParams = useSearchParams();
const pageNumber = searchParams.get("page") || "1";
const complianceId = searchParams.get("complianceId");
const openFindingId = searchParams.get("id");
const defaultSort = "severity,status,-inserted_at";
const sort = searchParams.get("sort") || defaultSort;
const loadedPageRef = useRef<string | null>(null);
@@ -160,7 +159,12 @@ export const ClientAccordionContent = ({
<h4 className="mb-2 text-sm font-medium">Findings</h4>
<DataTable
columns={getStandaloneFindingColumns({ openFindingId })}
// Remove select and updated_at columns for compliance view
columns={getColumnFindings({}, 0).filter(
(col) =>
col.id !== "select" &&
!("accessorKey" in col && col.accessorKey === "updated_at"),
)}
data={expandedFindings || []}
metadata={findings?.meta}
disableScroll={true}

View File

@@ -1,8 +1,14 @@
// TODO: Legacy columns — used by overview dashboard (column-new-findings-to-date.tsx).
// Migrate that consumer to grouped view columns, then delete this file.
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ColumnDef, RowSelectionState } from "@tanstack/react-table";
import { Database } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { FindingDetail } from "@/components/findings/table";
import { DataTableRowActions } from "@/components/findings/table";
import { Checkbox } from "@/components/shadcn";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { DateWithTime } from "@/components/ui/entities";
import {
@@ -12,15 +18,11 @@ import {
} from "@/components/ui/table";
import { FindingProps, ProviderType } from "@/types";
import { FindingDetailDrawer } from "./finding-detail-drawer";
// TODO: PROWLER-379 - Enable ImpactedResourcesCell when backend supports grouped findings
// import { ImpactedResourcesCell } from "./impacted-resources-cell";
import { DeltaValues, NotificationIndicator } from "./notification-indicator";
import { ProviderIconCell } from "./provider-icon-cell";
interface GetStandaloneFindingColumnsOptions {
includeUpdatedAt?: boolean;
openFindingId?: string | null;
}
const getFindingsData = (row: { original: FindingProps }) => {
return row.original;
};
@@ -43,38 +45,49 @@ const getProviderData = (
return row.original.relationships?.provider?.attributes?.[field] || "-";
};
function FindingTitleCell({
finding,
defaultOpen = false,
}: {
finding: FindingProps;
defaultOpen?: boolean;
}) {
// Component for finding title that opens the detail drawer
const FindingTitleCell = ({ row }: { row: { original: FindingProps } }) => {
const searchParams = useSearchParams();
const findingId = searchParams.get("id");
const isOpen = findingId === row.original.id;
const { checktitle } = row.original.attributes.check_metadata;
return (
<FindingDetailDrawer
finding={finding}
defaultOpen={defaultOpen}
<FindingDetail
findingDetails={row.original}
defaultOpen={isOpen}
trigger={
<div className="max-w-[500px]">
<p className="text-text-neutral-primary hover:text-button-tertiary cursor-pointer text-left text-sm break-words whitespace-normal hover:underline">
{finding.attributes.check_metadata.checktitle}
{checktitle}
</p>
</div>
}
/>
);
}
};
export function getStandaloneFindingColumns({
includeUpdatedAt = false,
openFindingId = null,
}: GetStandaloneFindingColumnsOptions = {}): ColumnDef<FindingProps>[] {
const columns: ColumnDef<FindingProps>[] = [
// Function to generate columns with access to selection state
export function getColumnFindings(
rowSelection: RowSelectionState,
selectableRowCount: number,
): ColumnDef<FindingProps>[] {
// Calculate selection state from rowSelection for header checkbox
const selectedCount = Object.values(rowSelection).filter(Boolean).length;
const isAllSelected =
selectedCount > 0 && selectedCount === selectableRowCount;
const isSomeSelected =
selectedCount > 0 && selectedCount < selectableRowCount;
return [
// Notification column - shows new/changed/muted indicators
{
id: "notification",
header: () => null,
cell: ({ row }) => {
const finding = row.original;
const isMuted = finding.attributes.muted;
const mutedReason = finding.attributes.muted_reason;
const delta = finding.attributes.delta as
| (typeof DeltaValues)[keyof typeof DeltaValues]
| undefined;
@@ -82,8 +95,8 @@ export function getStandaloneFindingColumns({
return (
<NotificationIndicator
delta={delta}
isMuted={finding.attributes.muted}
mutedReason={finding.attributes.muted_reason}
isMuted={isMuted}
mutedReason={mutedReason}
showDeltaWhenMuted
/>
);
@@ -91,6 +104,51 @@ export function getStandaloneFindingColumns({
enableSorting: false,
enableHiding: false,
},
// Select column
{
id: "select",
header: ({ table }) => {
const headerChecked = isAllSelected
? true
: isSomeSelected
? "indeterminate"
: false;
return (
<div className="ml-1 flex w-6 items-center justify-center pr-4">
<Checkbox
checked={headerChecked}
onCheckedChange={(checked) =>
table.toggleAllPageRowsSelected(checked === true)
}
aria-label="Select all"
disabled={selectableRowCount === 0}
/>
</div>
);
},
cell: ({ row }) => {
const finding = row.original;
const isMuted = finding.attributes.muted;
const isSelected = !!rowSelection[row.id];
return (
<div className="ml-1 flex w-6 items-center justify-center pr-4">
<Checkbox
checked={isSelected}
disabled={isMuted}
onCheckedChange={(checked) =>
row.toggleSelected(checked === true)
}
aria-label="Select row"
/>
</div>
);
},
enableSorting: false,
enableHiding: false,
},
// Status column
{
accessorKey: "status",
header: ({ column }) => (
@@ -104,6 +162,7 @@ export function getStandaloneFindingColumns({
return <StatusFindingBadge status={status} />;
},
},
// Finding column - clickable to open detail sheet
{
accessorKey: "check",
header: ({ column }) => (
@@ -113,13 +172,9 @@ export function getStandaloneFindingColumns({
param="check_id"
/>
),
cell: ({ row }) => (
<FindingTitleCell
finding={row.original}
defaultOpen={openFindingId === row.original.id}
/>
),
cell: ({ row }) => <FindingTitleCell row={row} />,
},
// Resource name column
{
accessorKey: "resourceName",
header: ({ column }) => (
@@ -142,6 +197,7 @@ export function getStandaloneFindingColumns({
},
enableSorting: false,
},
// Severity column
{
accessorKey: "severity",
header: ({ column }) => (
@@ -158,6 +214,7 @@ export function getStandaloneFindingColumns({
return <SeverityBadge severity={severity} />;
},
},
// Provider column
{
accessorKey: "provider",
header: ({ column }) => (
@@ -170,6 +227,7 @@ export function getStandaloneFindingColumns({
},
enableSorting: false,
},
// Service column
{
accessorKey: "service",
header: ({ column }) => (
@@ -185,6 +243,7 @@ export function getStandaloneFindingColumns({
},
enableSorting: false,
},
// Region column
{
accessorKey: "region",
header: ({ column }) => (
@@ -201,10 +260,19 @@ export function getStandaloneFindingColumns({
},
enableSorting: false,
},
];
if (includeUpdatedAt) {
columns.push({
// TODO: PROWLER-379 - Enable Impacted Resources column when backend supports grouped findings
// {
// accessorKey: "impactedResources",
// header: ({ column }) => (
// <DataTableColumnHeader column={column} title="Impacted Resources" />
// ),
// cell: () => {
// return <ImpactedResourcesCell impacted={1} total={1} />;
// },
// enableSorting: false,
// },
// Time column
{
accessorKey: "updated_at",
header: ({ column }) => (
<DataTableColumnHeader
@@ -219,8 +287,13 @@ export function getStandaloneFindingColumns({
} = getFindingsData(row);
return <DateWithTime dateTime={updated_at} />;
},
});
}
return columns;
},
// Actions column - dropdown with Mute/Jira options
{
id: "actions",
header: () => <div className="w-10" />,
cell: ({ row }) => <DataTableRowActions row={row} />,
enableSorting: false,
},
];
}

View File

@@ -0,0 +1,14 @@
"use client";
import { FindingProps } from "@/types/components";
import { FindingDetail } from "./finding-detail";
export const DataTableRowDetails = ({
findingDetails,
}: {
entityId: string;
findingDetails: FindingProps;
}) => {
return <FindingDetail findingDetails={findingDetails} />;
};

View File

@@ -1,22 +0,0 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("finding detail drawer", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const filePath = path.join(currentDir, "finding-detail-drawer.tsx");
const source = readFileSync(filePath, "utf8");
it("uses the shared resource detail drawer hook with single-resource mode", () => {
expect(source).toContain("useResourceDetailDrawer");
expect(source).toContain("totalResourceCount: 1");
expect(source).toContain("initialIndex: defaultOpen || inline ? 0 : null");
});
it("renders the new resource detail drawer content instead of the legacy finding detail component", () => {
expect(source).toContain("ResourceDetailDrawerContent");
expect(source).not.toContain('from "./finding-detail"');
});
});

View File

@@ -1,98 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { findingToFindingResourceRow } from "@/lib/finding-detail";
import type { FindingProps } from "@/types/components";
import {
ResourceDetailDrawer,
useResourceDetailDrawer,
} from "./resource-detail-drawer";
import { ResourceDetailDrawerContent } from "./resource-detail-drawer/resource-detail-drawer-content";
interface FindingDetailDrawerProps {
finding: FindingProps;
trigger?: ReactNode;
defaultOpen?: boolean;
inline?: boolean;
onOpenChange?: (open: boolean) => void;
onMuteComplete?: () => void;
}
export function FindingDetailDrawer({
finding,
trigger,
defaultOpen = false,
inline = false,
onOpenChange,
onMuteComplete,
}: FindingDetailDrawerProps) {
const drawer = useResourceDetailDrawer({
resources: [findingToFindingResourceRow(finding)],
checkId: finding.attributes.check_id,
totalResourceCount: 1,
initialIndex: defaultOpen || inline ? 0 : null,
});
const handleOpen = () => {
drawer.openDrawer(0);
onOpenChange?.(true);
};
const handleOpenChange = (open: boolean) => {
if (open) {
drawer.openDrawer(0);
} else {
drawer.closeDrawer();
}
onOpenChange?.(open);
};
const handleMuteComplete = () => {
drawer.refetchCurrent();
onMuteComplete?.();
};
if (inline) {
return (
<ResourceDetailDrawerContent
isLoading={drawer.isLoading}
isNavigating={drawer.isNavigating}
checkMeta={drawer.checkMeta}
currentIndex={drawer.currentIndex}
totalResources={drawer.totalResources}
currentFinding={drawer.currentFinding}
otherFindings={drawer.otherFindings}
onNavigatePrev={drawer.navigatePrev}
onNavigateNext={drawer.navigateNext}
onMuteComplete={handleMuteComplete}
/>
);
}
return (
<>
{trigger ? (
<button type="button" className="contents" onClick={handleOpen}>
{trigger}
</button>
) : null}
<ResourceDetailDrawer
open={drawer.isOpen}
onOpenChange={handleOpenChange}
isLoading={drawer.isLoading}
isNavigating={drawer.isNavigating}
checkMeta={drawer.checkMeta}
currentIndex={drawer.currentIndex}
totalResources={drawer.totalResources}
currentFinding={drawer.currentFinding}
otherFindings={drawer.otherFindings}
onNavigatePrev={drawer.navigatePrev}
onNavigateNext={drawer.navigateNext}
onMuteComplete={handleMuteComplete}
/>
</>
);
}

View File

@@ -0,0 +1,299 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { FindingProps } from "@/types";
import { FindingDetail } from "./finding-detail";
// Mock next/navigation
const mockRefresh = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: mockRefresh }),
usePathname: () => "/findings",
useSearchParams: () => new URLSearchParams(),
}));
// Mock @/components/shadcn to avoid next-auth import chain
vi.mock("@/components/shadcn", () => {
const Slot = ({ children }: { children: React.ReactNode }) => <>{children}</>;
return {
Button: ({
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: string;
size?: string;
}) => <button {...props}>{children}</button>,
Drawer: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DrawerClose: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
DrawerContent: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
DrawerDescription: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
DrawerHeader: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
DrawerTitle: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
DrawerTrigger: Slot,
InfoField: ({
children,
label,
}: {
children: React.ReactNode;
label: string;
variant?: string;
}) => (
<div>
<span>{label}</span>
{children}
</div>
),
Tabs: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TabsContent: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
TabsList: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TabsTrigger: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
TooltipTrigger: Slot,
};
});
vi.mock("@/components/ui/code-snippet/code-snippet", () => ({
CodeSnippet: ({ value }: { value: string }) => <span>{value}</span>,
}));
vi.mock("@/components/ui/custom/custom-link", () => ({
CustomLink: ({ children }: { children: React.ReactNode }) => (
<span>{children}</span>
),
}));
vi.mock("@/components/ui/entities", () => ({
EntityInfo: () => <div data-testid="entity-info" />,
}));
vi.mock("@/components/ui/entities/date-with-time", () => ({
DateWithTime: ({ dateTime }: { dateTime: string }) => <span>{dateTime}</span>,
}));
vi.mock("@/components/ui/table/severity-badge", () => ({
SeverityBadge: ({ severity }: { severity: string }) => (
<span>{severity}</span>
),
}));
vi.mock("@/components/ui/table/status-finding-badge", () => ({
FindingStatus: {},
StatusFindingBadge: ({ status }: { status: string }) => <span>{status}</span>,
}));
vi.mock("@/lib/iac-utils", () => ({
buildGitFileUrl: () => null,
extractLineRangeFromUid: () => null,
}));
vi.mock("@/lib/utils", () => ({
cn: (...args: string[]) => args.filter(Boolean).join(" "),
}));
// Mock child components that are not under test
vi.mock("../mute-findings-modal", () => ({
MuteFindingsModal: ({
isOpen,
findingIds,
}: {
isOpen: boolean;
findingIds: string[];
}) =>
isOpen ? (
<div data-testid="mute-modal">Muting {findingIds.length} finding(s)</div>
) : null,
}));
vi.mock("../muted", () => ({
Muted: ({ isMuted }: { isMuted: boolean }) =>
isMuted ? <span data-testid="muted-badge">Muted</span> : null,
}));
vi.mock("./delta-indicator", () => ({
DeltaIndicator: () => null,
}));
vi.mock("@/components/shared/events-timeline/events-timeline", () => ({
EventsTimeline: () => <div data-testid="events-timeline" />,
}));
vi.mock("react-markdown", () => ({
default: ({ children }: { children: string }) => <span>{children}</span>,
}));
const baseFinding: FindingProps = {
type: "findings",
id: "finding-123",
attributes: {
uid: "uid-123",
delta: null,
status: "FAIL",
status_extended: "S3 bucket is publicly accessible",
severity: "high",
check_id: "s3_bucket_public_access",
muted: false,
check_metadata: {
risk: "Public access risk",
notes: "",
checkid: "s3_bucket_public_access",
provider: "aws",
severity: "high",
checktype: [],
dependson: [],
relatedto: [],
categories: ["security"],
checktitle: "S3 Bucket Public Access Check",
compliance: null,
relatedurl: "",
description: "Checks if S3 buckets are publicly accessible",
remediation: {
code: { cli: "", other: "", nativeiac: "", terraform: "" },
recommendation: { url: "", text: "" },
},
servicename: "s3",
checkaliases: [],
resourcetype: "AwsS3Bucket",
subservicename: "",
resourceidtemplate: "",
},
raw_result: null,
inserted_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-02T00:00:00Z",
first_seen_at: "2024-01-01T00:00:00Z",
},
relationships: {
resources: { data: [{ type: "resources", id: "res-1" }] },
scan: {
data: { type: "scans", id: "scan-1" },
attributes: {
name: "Daily Scan",
trigger: "scheduled",
state: "completed",
unique_resource_count: 50,
progress: 100,
scanner_args: { checks_to_execute: [] },
duration: 120,
started_at: "2024-01-01T00:00:00Z",
inserted_at: "2024-01-01T00:00:00Z",
completed_at: "2024-01-01T00:02:00Z",
scheduled_at: null,
next_scan_at: "2024-01-02T00:00:00Z",
},
},
resource: {
data: [{ type: "resources", id: "res-1" }],
id: "res-1",
attributes: {
uid: "arn:aws:s3:::my-bucket",
name: "my-bucket",
region: "us-east-1",
service: "s3",
tags: {},
type: "AwsS3Bucket",
inserted_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
details: null,
partition: "aws",
},
relationships: {
provider: { data: { type: "providers", id: "prov-1" } },
findings: {
meta: { count: 1 },
data: [{ type: "findings", id: "finding-123" }],
},
},
links: { self: "/resources/res-1" },
},
provider: {
data: { type: "providers", id: "prov-1" },
attributes: {
provider: "aws",
uid: "123456789012",
alias: "my-account",
connection: {
connected: true,
last_checked_at: "2024-01-01T00:00:00Z",
},
inserted_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
relationships: {
secret: { data: { type: "provider-secrets", id: "secret-1" } },
},
links: { self: "/providers/prov-1" },
},
},
links: { self: "/findings/finding-123" },
};
describe("FindingDetail", () => {
it("shows the Mute button for non-muted findings", () => {
render(<FindingDetail findingDetails={baseFinding} />);
expect(screen.getByRole("button", { name: /mute/i })).toBeInTheDocument();
});
it("hides the Mute button for muted findings", () => {
const mutedFinding: FindingProps = {
...baseFinding,
attributes: { ...baseFinding.attributes, muted: true },
};
render(<FindingDetail findingDetails={mutedFinding} />);
expect(screen.queryByRole("button", { name: /mute/i })).toBeNull();
});
it("opens the mute modal when clicking the Mute button", async () => {
const user = userEvent.setup();
render(<FindingDetail findingDetails={baseFinding} />);
expect(screen.queryByTestId("mute-modal")).toBeNull();
await user.click(screen.getByRole("button", { name: /mute/i }));
expect(screen.getByTestId("mute-modal")).toBeInTheDocument();
});
it("does not render the mute modal for muted findings", () => {
const mutedFinding: FindingProps = {
...baseFinding,
attributes: { ...baseFinding.attributes, muted: true },
};
render(<FindingDetail findingDetails={mutedFinding} />);
expect(screen.queryByTestId("mute-modal")).toBeNull();
});
it("shows the muted badge for muted findings", () => {
const mutedFinding: FindingProps = {
...baseFinding,
attributes: { ...baseFinding.attributes, muted: true },
};
render(<FindingDetail findingDetails={mutedFinding} />);
expect(screen.getByTestId("muted-badge")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,502 @@
// TODO: Legacy component — used by /resources page and overview dashboard.
// Migrate those consumers to the new resource-detail-drawer, then delete this file.
"use client";
import { ExternalLink, Link, VolumeX, X } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { type ReactNode, useState } from "react";
import {
Button,
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
InfoField,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn";
import { EventsTimeline } from "@/components/shared/events-timeline/events-timeline";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { EntityInfo } from "@/components/ui/entities";
import { DateWithTime } from "@/components/ui/entities/date-with-time";
import { SeverityBadge } from "@/components/ui/table/severity-badge";
import {
FindingStatus,
StatusFindingBadge,
} from "@/components/ui/table/status-finding-badge";
import { formatDuration } from "@/lib/date-utils";
import { buildGitFileUrl, extractLineRangeFromUid } from "@/lib/iac-utils";
import { cn } from "@/lib/utils";
import { FindingProps, ProviderType } from "@/types";
import { MarkdownContainer } from "../markdown-container";
import { MuteFindingsModal } from "../mute-findings-modal";
import { Muted } from "../muted";
import { DeltaIndicator } from "./delta-indicator";
const renderValue = (value: string | null | undefined) => {
return value && value.trim() !== "" ? value : "-";
};
interface FindingDetailProps {
findingDetails: FindingProps;
trigger?: ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}
export const FindingDetail = ({
findingDetails,
trigger,
open,
defaultOpen = false,
onOpenChange,
}: FindingDetailProps) => {
const finding = findingDetails;
const attributes = finding.attributes;
const resource = finding.relationships?.resource?.attributes;
const scan = finding.relationships?.scan?.attributes;
const providerDetails = finding.relationships?.provider?.attributes;
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
const copyFindingUrl = () => {
const params = new URLSearchParams(searchParams.toString());
params.set("id", findingDetails.id);
const url = `${window.location.origin}${pathname}?${params.toString()}`;
navigator.clipboard.writeText(url);
};
// Build Git URL for IaC findings
const gitUrl =
providerDetails?.provider === "iac" && resource
? buildGitFileUrl(
providerDetails.uid,
resource.name,
extractLineRangeFromUid(attributes.uid) || "",
resource.region,
)
: null;
const handleMuteComplete = () => {
setIsMuteModalOpen(false);
onOpenChange?.(false);
router.refresh();
};
const muteModal = !attributes.muted && (
<MuteFindingsModal
isOpen={isMuteModalOpen}
onOpenChange={setIsMuteModalOpen}
findingIds={[findingDetails.id]}
onComplete={handleMuteComplete}
/>
);
const content = (
<div className="flex min-w-0 flex-col gap-4 rounded-lg">
{/* Header */}
<div className="flex flex-col gap-2">
{/* Row 1: Status badges */}
<div className="flex flex-wrap items-center gap-4">
<StatusFindingBadge status={attributes.status as FindingStatus} />
<SeverityBadge severity={attributes.severity || "-"} />
{attributes.delta && (
<div className="flex items-center gap-1 capitalize">
<DeltaIndicator delta={attributes.delta} />
<span className="text-text-neutral-secondary text-xs">
{attributes.delta}
</span>
</div>
)}
<Muted
isMuted={attributes.muted}
mutedReason={attributes.muted_reason || ""}
/>
</div>
{/* Row 2: Title with copy link */}
<h2 className="text-text-neutral-primary line-clamp-2 flex items-center gap-2 text-lg leading-tight font-medium">
{renderValue(attributes.check_metadata.checktitle)}
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={copyFindingUrl}
className="text-bg-data-info inline-flex cursor-pointer transition-opacity hover:opacity-80"
aria-label="Copy finding link to clipboard"
>
<Link size={16} />
</button>
</TooltipTrigger>
<TooltipContent>Copy finding link to clipboard</TooltipContent>
</Tooltip>
</h2>
{/* Row 3: First Seen */}
<div className="text-text-neutral-tertiary text-sm">
<span className="text-text-neutral-secondary mr-1">Time:</span>
<DateWithTime inline dateTime={attributes.updated_at || "-"} />
</div>
</div>
{/* Tabs */}
<Tabs key={findingDetails.id} defaultValue="general" className="w-full">
<div className="mb-4 flex items-center justify-between">
<TabsList>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="resources">Resources</TabsTrigger>
<TabsTrigger value="scans">Scans</TabsTrigger>
<TabsTrigger value="events">Events</TabsTrigger>
</TabsList>
{!attributes.muted && (
<Button
variant="outline"
size="sm"
onClick={() => setIsMuteModalOpen(true)}
>
<VolumeX className="size-4" />
Mute
</Button>
)}
</div>
{/* General Tab */}
<TabsContent value="general" className="flex flex-col gap-4">
<p className="text-text-neutral-primary text-sm">
Here is an overview of this finding:
</p>
<div className="flex flex-wrap gap-4">
{providerDetails && (
<EntityInfo
cloudProvider={providerDetails.provider as ProviderType}
entityAlias={providerDetails.alias}
entityId={providerDetails.uid}
showConnectionStatus={providerDetails.connection.connected}
/>
)}
<InfoField label="Service">
{attributes.check_metadata.servicename}
</InfoField>
<InfoField label="Region">{resource?.region ?? "-"}</InfoField>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<InfoField label="Check ID" variant="simple">
<CodeSnippet value={attributes.check_id} className="max-w-full" />
</InfoField>
<InfoField label="Finding ID" variant="simple">
<CodeSnippet value={findingDetails.id} className="max-w-full" />
</InfoField>
<InfoField label="Finding UID" variant="simple">
<CodeSnippet value={attributes.uid} className="max-w-full" />
</InfoField>
<InfoField label="First seen" variant="simple">
<DateWithTime inline dateTime={attributes.first_seen_at || "-"} />
</InfoField>
</div>
{attributes.status === "FAIL" && (
<InfoField label="Risk" variant="simple">
<div
className={cn(
"max-w-full rounded-md border p-2",
"border-border-error-primary bg-bg-fail-secondary",
)}
>
<MarkdownContainer>
{attributes.check_metadata.risk}
</MarkdownContainer>
</div>
</InfoField>
)}
<InfoField label="Description">
<MarkdownContainer>
{attributes.check_metadata.description}
</MarkdownContainer>
</InfoField>
<InfoField label="Status Extended">
{renderValue(attributes.status_extended)}
</InfoField>
{attributes.check_metadata.remediation && (
<div className="flex flex-col gap-4">
<h4 className="text-text-neutral-primary text-sm font-bold">
Remediation Details
</h4>
{/* Recommendation section */}
{attributes.check_metadata.remediation.recommendation.text && (
<InfoField label="Recommendation">
<div className="flex flex-col gap-2">
<MarkdownContainer>
{
attributes.check_metadata.remediation.recommendation
.text
}
</MarkdownContainer>
{attributes.check_metadata.remediation.recommendation
.url && (
<CustomLink
href={
attributes.check_metadata.remediation.recommendation
.url
}
size="sm"
>
Learn more
</CustomLink>
)}
</div>
</InfoField>
)}
{/* CLI Command section */}
{attributes.check_metadata.remediation.code.cli && (
<InfoField label="CLI Command" variant="simple">
<div
className={cn("rounded-md p-2", "bg-bg-neutral-tertiary")}
>
<span className="text-xs whitespace-pre-line">
{attributes.check_metadata.remediation.code.cli}
</span>
</div>
</InfoField>
)}
{/* Remediation Steps section */}
{attributes.check_metadata.remediation.code.other && (
<InfoField label="Remediation Steps">
<MarkdownContainer>
{attributes.check_metadata.remediation.code.other}
</MarkdownContainer>
</InfoField>
)}
{/* Additional URLs section */}
{attributes.check_metadata.additionalurls &&
attributes.check_metadata.additionalurls.length > 0 && (
<InfoField label="References">
<ul className="list-inside list-disc space-y-1">
{attributes.check_metadata.additionalurls.map(
(link, idx) => (
<li key={idx}>
<CustomLink
href={link}
size="sm"
className="break-all whitespace-normal!"
>
{link}
</CustomLink>
</li>
),
)}
</ul>
</InfoField>
)}
</div>
)}
<InfoField label="Categories">
{attributes.check_metadata.categories?.join(", ") || "none"}
</InfoField>
</TabsContent>
{/* Resources Tab */}
<TabsContent value="resources" className="flex flex-col gap-4">
{resource ? (
<>
{providerDetails?.provider === "iac" && gitUrl && (
<div className="flex justify-end">
<Tooltip>
<TooltipTrigger asChild>
<a
href={gitUrl}
target="_blank"
rel="noopener noreferrer"
className="text-bg-data-info inline-flex items-center gap-1 text-sm"
aria-label="Open resource in repository"
>
<ExternalLink size={16} />
View in Repository
</a>
</TooltipTrigger>
<TooltipContent>
Go to Resource in the Repository
</TooltipContent>
</Tooltip>
</div>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoField label="Resource Name">
{renderValue(resource.name)}
</InfoField>
<InfoField label="Resource Type">
{renderValue(resource.type)}
</InfoField>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoField label="Service">
{renderValue(resource.service)}
</InfoField>
<InfoField label="Region">
{renderValue(resource.region)}
</InfoField>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoField label="Partition">
{renderValue(resource.partition)}
</InfoField>
<InfoField label="Details">
{renderValue(resource.details)}
</InfoField>
</div>
<InfoField label="Resource ID" variant="simple">
<CodeSnippet value={resource.uid} />
</InfoField>
{resource.tags && Object.entries(resource.tags).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(resource.tags).map(([key, value]) => (
<InfoField key={key} label={key}>
{renderValue(value)}
</InfoField>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoField label="Created At">
<DateWithTime inline dateTime={resource.inserted_at || "-"} />
</InfoField>
<InfoField label="Last Updated">
<DateWithTime inline dateTime={resource.updated_at || "-"} />
</InfoField>
</div>
</>
) : (
<p className="text-text-neutral-tertiary text-sm">
Resource information is not available.
</p>
)}
</TabsContent>
{/* Scans Tab */}
<TabsContent value="scans" className="flex flex-col gap-4">
{scan ? (
<>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<InfoField label="Scan Name">{scan.name || "N/A"}</InfoField>
<InfoField label="Resources Scanned">
{scan.unique_resource_count}
</InfoField>
<InfoField label="Progress">{scan.progress}%</InfoField>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<InfoField label="Trigger">{scan.trigger}</InfoField>
<InfoField label="State">{scan.state}</InfoField>
<InfoField label="Duration">
{formatDuration(scan.duration)}
</InfoField>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoField label="Started At">
<DateWithTime inline dateTime={scan.started_at || "-"} />
</InfoField>
<InfoField label="Completed At">
<DateWithTime inline dateTime={scan.completed_at || "-"} />
</InfoField>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoField label="Launched At">
<DateWithTime inline dateTime={scan.inserted_at || "-"} />
</InfoField>
{scan.scheduled_at && (
<InfoField label="Scheduled At">
<DateWithTime inline dateTime={scan.scheduled_at} />
</InfoField>
)}
</div>
</>
) : (
<p className="text-text-neutral-tertiary text-sm">
Scan information is not available.
</p>
)}
</TabsContent>
{/* Events Tab */}
<TabsContent value="events" className="flex flex-col gap-4">
<EventsTimeline
resourceId={finding.relationships?.resource?.id}
isAwsProvider={providerDetails?.provider === "aws"}
/>
</TabsContent>
</Tabs>
</div>
);
// If no trigger, render content directly (inline mode)
if (!trigger) {
return (
<>
{muteModal}
{content}
</>
);
}
// With trigger, wrap in Drawer — modal rendered outside to avoid nested overlay issues
return (
<>
{muteModal}
<Drawer
direction="right"
open={open}
defaultOpen={defaultOpen}
onOpenChange={onOpenChange}
>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="minimal-scrollbar 3xl:w-1/3 h-full w-full overflow-x-hidden overflow-y-auto p-6 md:w-1/2 md:max-w-none">
<DrawerHeader className="sr-only">
<DrawerTitle>Finding Details</DrawerTitle>
<DrawerDescription>View the finding 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>
{content}
</DrawerContent>
</Drawer>
</>
);
};

View File

@@ -1,16 +0,0 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("findings group drill down", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const filePath = path.join(currentDir, "findings-group-drill-down.tsx");
const source = readFileSync(filePath, "utf8");
it("uses the shared finding-group resource state hook", () => {
expect(source).toContain("useFindingGroupResourceState");
expect(source).not.toContain("useInfiniteResources");
});
});

View File

@@ -3,12 +3,15 @@
import {
flexRender,
getCoreRowModel,
Row,
RowSelectionState,
useReactTable,
} from "@tanstack/react-table";
import { ChevronLeft } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import {
Table,
TableBody,
@@ -18,20 +21,24 @@ import {
TableRow,
} from "@/components/ui/table";
import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table";
import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state";
import { useInfiniteResources } from "@/hooks/use-infinite-resources";
import { cn, hasHistoricalFindingFilter } from "@/lib";
import {
getFilteredFindingGroupDelta,
isFindingGroupMuted,
} from "@/lib/findings-groups";
import { FindingGroupRow } from "@/types";
import { FindingGroupRow, FindingResourceRow } from "@/types";
import { FloatingMuteButton } from "../floating-mute-button";
import { getColumnFindingResources } from "./column-finding-resources";
import { canMuteFindingResource } from "./finding-resource-selection";
import { FindingsSelectionContext } from "./findings-selection-context";
import { ImpactedResourcesCell } from "./impacted-resources-cell";
import { DeltaValues, NotificationIndicator } from "./notification-indicator";
import { ResourceDetailDrawer } from "./resource-detail-drawer";
import {
ResourceDetailDrawer,
useResourceDetailDrawer,
} from "./resource-detail-drawer";
interface FindingsGroupDrillDownProps {
group: FindingGroupRow;
@@ -43,6 +50,9 @@ export function FindingsGroupDrillDown({
onCollapse,
}: FindingsGroupDrillDownProps) {
const searchParams = useSearchParams();
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [resources, setResources] = useState<FindingResourceRow[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Keep drill-down endpoint selection aligned with the grouped findings page.
const currentParams = Object.fromEntries(searchParams.entries());
@@ -56,27 +66,78 @@ export function FindingsGroupDrillDown({
}
});
const {
rowSelection,
resources,
isLoading,
sentinelRef,
drawer,
handleDrawerMuteComplete,
selectedFindingIds,
selectableRowCount,
getRowCanSelect,
clearSelection,
isSelected,
handleMuteComplete,
handleRowSelectionChange,
resolveSelectedFindingIds,
} = useFindingGroupResourceState({
group,
const handleSetResources = (
newResources: FindingResourceRow[],
_hasMore: boolean,
) => {
setResources(newResources);
setIsLoading(false);
};
const handleAppendResources = (
newResources: FindingResourceRow[],
_hasMore: boolean,
) => {
setResources((prev) => [...prev, ...newResources]);
setIsLoading(false);
};
const handleSetLoading = (loading: boolean) => {
setIsLoading(loading);
};
const { sentinelRef, refresh, loadMore, totalCount } = useInfiniteResources({
checkId: group.checkId,
hasDateOrScanFilter: hasHistoricalFilterActive,
filters,
hasHistoricalData: hasHistoricalFilterActive,
onSetResources: handleSetResources,
onAppendResources: handleAppendResources,
onSetLoading: handleSetLoading,
});
// Resource detail drawer
const drawer = useResourceDetailDrawer({
resources,
checkId: group.checkId,
totalResourceCount: totalCount ?? group.resourcesTotal,
onRequestMoreResources: loadMore,
});
const handleDrawerMuteComplete = () => {
drawer.refetchCurrent();
refresh();
};
// Selection logic — tracks by findingId (resource_id) for checkbox consistency
const selectedFindingIds = Object.keys(rowSelection)
.filter((key) => rowSelection[key])
.map((idx) => resources[parseInt(idx)]?.findingId)
.filter((id): id is string => id !== null && id !== undefined && id !== "");
/** findingId values are already real finding UUIDs — no resolution needed. */
const resolveResourceIds = async (ids: string[]) => {
return ids.filter(Boolean);
};
const selectableRowCount = resources.filter(canMuteFindingResource).length;
const getRowCanSelect = (row: Row<FindingResourceRow>): boolean => {
return canMuteFindingResource(row.original);
};
const clearSelection = () => {
setRowSelection({});
};
const isSelected = (id: string) => {
return selectedFindingIds.includes(id);
};
const handleMuteComplete = () => {
clearSelection();
refresh();
};
const columns = getColumnFindingResources({
rowSelection,
selectableRowCount,
@@ -87,7 +148,7 @@ export function FindingsGroupDrillDown({
columns,
enableRowSelection: getRowCanSelect,
getCoreRowModel: getCoreRowModel(),
onRowSelectionChange: handleRowSelectionChange,
onRowSelectionChange: setRowSelection,
manualPagination: true,
state: {
rowSelection,
@@ -114,7 +175,7 @@ export function FindingsGroupDrillDown({
selectedFindings: [],
clearSelection,
isSelected,
resolveMuteIds: resolveSelectedFindingIds,
resolveMuteIds: resolveResourceIds,
onMuteComplete: handleMuteComplete,
}}
>
@@ -219,7 +280,14 @@ export function FindingsGroupDrillDown({
</Table>
{/* Loading indicator */}
{isLoading && <LoadingState label="Loading resources..." />}
{isLoading && (
<div className="flex items-center justify-center gap-2 py-8">
<Spinner className="size-6" />
<span className="text-text-neutral-tertiary text-sm">
Loading resources...
</span>
</div>
)}
{/* Sentinel for infinite scroll */}
<div ref={sentinelRef} className="h-1" />
@@ -231,7 +299,7 @@ export function FindingsGroupDrillDown({
selectedCount={selectedFindingIds.length}
selectedFindingIds={selectedFindingIds}
onBeforeOpen={async () => {
return resolveSelectedFindingIds(selectedFindingIds);
return resolveResourceIds(selectedFindingIds);
}}
onComplete={handleMuteComplete}
isBulkOperation

View File

@@ -1,11 +1,16 @@
export * from "./column-finding-groups";
export * from "./column-finding-resources";
export * from "./column-standalone-findings";
export * from "./column-findings";
export * from "./data-table-row-actions";
export * from "./finding-detail-drawer";
export * from "./data-table-row-details";
export * from "./finding-detail";
export * from "./findings-group-drill-down";
export * from "./findings-group-table";
export * from "./findings-selection-context";
// TODO: Remove legacy exports once /resources and overview dashboard migrate to grouped view components
// export * from "./column-findings";
// export * from "./data-table-row-details";
// export * from "./finding-detail";
export * from "./impacted-resources-cell";
export * from "./notification-indicator";
export * from "./provider-icon-cell";

View File

@@ -1,16 +0,0 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("inline resource container", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const filePath = path.join(currentDir, "inline-resource-container.tsx");
const source = readFileSync(filePath, "utf8");
it("uses the shared finding-group resource state hook", () => {
expect(source).toContain("useFindingGroupResourceState");
expect(source).not.toContain("useInfiniteResources");
});
});

View File

@@ -3,26 +3,32 @@
import {
flexRender,
getCoreRowModel,
Row,
RowSelectionState,
useReactTable,
} from "@tanstack/react-table";
import { AnimatePresence, motion } from "framer-motion";
import { ChevronsDown } from "lucide-react";
import { useImperativeHandle, useRef } from "react";
import { useImperativeHandle, useRef, useState } from "react";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { TableCell, TableRow } from "@/components/ui/table";
import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state";
import { useInfiniteResources } from "@/hooks/use-infinite-resources";
import { useScrollHint } from "@/hooks/use-scroll-hint";
import { FindingGroupRow } from "@/types";
import { FindingGroupRow, FindingResourceRow } from "@/types";
import { getColumnFindingResources } from "./column-finding-resources";
import { canMuteFindingResource } from "./finding-resource-selection";
import { FindingsSelectionContext } from "./findings-selection-context";
import {
getFilteredFindingGroupResourceCount,
getFindingGroupSkeletonCount,
} from "./inline-resource-container.utils";
import { ResourceDetailDrawer } from "./resource-detail-drawer";
import {
ResourceDetailDrawer,
useResourceDetailDrawer,
} from "./resource-detail-drawer";
export interface InlineResourceContainerHandle {
/** Soft-refresh resources (re-fetch page 1 without skeletons). */
@@ -134,6 +140,22 @@ export function InlineResourceContainer({
ref,
}: InlineResourceContainerProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [resources, setResources] = useState<FindingResourceRow[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Scroll hint: shows "scroll for more" when content overflows
const {
containerRef: scrollHintContainerRef,
sentinelRef: scrollHintSentinelRef,
showScrollHint,
} = useScrollHint({ refreshToken: resources.length });
// Combine scrollContainerRef (for IntersectionObserver root) with scrollHintContainerRef
const combinedScrollRef = (node: HTMLDivElement | null) => {
scrollContainerRef.current = node;
scrollHintContainerRef(node);
};
const filters: Record<string, string> = { ...resolvedFilters };
if (resourceSearch) {
filters["filter[name__icontains]"] = resourceSearch;
@@ -149,45 +171,99 @@ export function InlineResourceContainer({
filters,
);
const {
rowSelection,
resources,
isLoading,
sentinelRef,
refresh,
drawer,
handleDrawerMuteComplete,
selectedFindingIds,
selectableRowCount,
getRowCanSelect,
clearSelection,
isSelected,
handleMuteComplete,
handleRowSelectionChange,
resolveSelectedFindingIds,
} = useFindingGroupResourceState({
group,
const handleSetResources = (
newResources: FindingResourceRow[],
_hasMore: boolean,
) => {
setResources(newResources);
setIsLoading(false);
};
const handleAppendResources = (
newResources: FindingResourceRow[],
_hasMore: boolean,
) => {
setResources((prev) => [...prev, ...newResources]);
setIsLoading(false);
};
const handleSetLoading = (loading: boolean) => {
setIsLoading(loading);
};
const { sentinelRef, refresh, loadMore, totalCount } = useInfiniteResources({
checkId: group.checkId,
hasDateOrScanFilter: hasHistoricalData,
filters,
hasHistoricalData,
onResourceSelectionChange,
onSetResources: handleSetResources,
onAppendResources: handleAppendResources,
onSetLoading: handleSetLoading,
scrollContainerRef,
});
// Scroll hint: shows "scroll for more" when content overflows
const {
containerRef: scrollHintContainerRef,
sentinelRef: scrollHintSentinelRef,
showScrollHint,
} = useScrollHint({ refreshToken: resources.length });
// Resource detail drawer
const drawer = useResourceDetailDrawer({
resources,
checkId: group.checkId,
totalResourceCount: totalCount ?? group.resourcesTotal,
onRequestMoreResources: loadMore,
});
// Combine scrollContainerRef (for IntersectionObserver root) with scrollHintContainerRef
const combinedScrollRef = (node: HTMLDivElement | null) => {
scrollContainerRef.current = node;
scrollHintContainerRef(node);
const handleDrawerMuteComplete = () => {
drawer.refetchCurrent();
refresh();
};
// Selection logic
const selectedFindingIds = Object.keys(rowSelection)
.filter((key) => rowSelection[key])
.map((idx) => resources[parseInt(idx)]?.findingId)
.filter(Boolean);
const resolveResourceIds = async (ids: string[]) => {
// findingId values are already real finding UUIDs (from the group
// resources endpoint), so no second resolution round-trip is needed.
return ids.filter(Boolean);
};
const selectableRowCount = resources.filter(canMuteFindingResource).length;
const getRowCanSelect = (row: Row<FindingResourceRow>): boolean => {
return canMuteFindingResource(row.original);
};
const clearSelection = () => {
setRowSelection({});
onResourceSelectionChange([]);
};
useImperativeHandle(ref, () => ({ refresh, clearSelection }));
const isSelected = (id: string) => {
return selectedFindingIds.includes(id);
};
const handleMuteComplete = () => {
clearSelection();
refresh();
};
const handleRowSelectionChange = (
updater:
| RowSelectionState
| ((prev: RowSelectionState) => RowSelectionState),
) => {
const newSelection =
typeof updater === "function" ? updater(rowSelection) : updater;
setRowSelection(newSelection);
const newFindingIds = Object.keys(newSelection)
.filter((key) => newSelection[key])
.map((idx) => resources[parseInt(idx)]?.findingId)
.filter(Boolean);
onResourceSelectionChange(newFindingIds);
};
const columns = getColumnFindingResources({
rowSelection,
selectableRowCount,
@@ -214,7 +290,7 @@ export function InlineResourceContainer({
selectedFindings: [],
clearSelection,
isSelected,
resolveMuteIds: resolveSelectedFindingIds,
resolveMuteIds: resolveResourceIds,
onMuteComplete: handleMuteComplete,
}}
>
@@ -287,9 +363,14 @@ export function InlineResourceContainer({
</tbody>
</table>
{/* Loading state for infinite scroll (subsequent pages only) */}
{/* Spinner for infinite scroll (subsequent pages only) */}
{isLoading && rows.length > 0 && (
<LoadingState label="Loading resources..." />
<div className="flex items-center justify-center gap-2 py-8">
<Spinner className="size-6" />
<span className="text-text-neutral-tertiary text-sm">
Loading resources...
</span>
</div>
)}
{/* Sentinel for scroll hint detection */}

View File

@@ -178,30 +178,16 @@ vi.mock("@/components/findings/markdown-container", () => ({
}));
vi.mock("@/components/shared/query-code-editor", () => ({
QUERY_EDITOR_LANGUAGE: {
OPEN_CYPHER: "openCypher",
PLAIN_TEXT: "plainText",
SHELL: "shell",
HCL: "hcl",
BICEP: "bicep",
YAML: "yaml",
},
QueryCodeEditor: ({
ariaLabel,
language,
value,
copyValue,
}: {
ariaLabel: string;
language?: string;
value: string;
copyValue?: string;
}) => (
<div
data-testid="query-code-editor"
data-aria-label={ariaLabel}
data-language={language}
>
<div data-testid="query-code-editor" data-aria-label={ariaLabel}>
<span>{ariaLabel}</span>
<span>{value}</span>
<button
@@ -526,34 +512,6 @@ describe("ResourceDetailDrawerContent — Fix 2: Remediation heading labels", ()
expect(mockClipboardWriteText).toHaveBeenCalledWith("aws s3 ...");
expect(screen.getByText("$ aws s3 ...")).toBeInTheDocument();
});
it("should pass syntax highlighting languages to each remediation editor", () => {
// Given
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");
// Then
expect(editors[0]).toHaveAttribute("data-language", "shell");
expect(editors[1]).toHaveAttribute("data-language", "hcl");
expect(editors[2]).toHaveAttribute("data-language", "yaml");
expect(editors[0]).toHaveAttribute("data-aria-label", "CLI Command");
expect(editors[2]).toHaveAttribute("data-aria-label", "CloudFormation");
});
});
// ---------------------------------------------------------------------------

View File

@@ -37,18 +37,14 @@ import {
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { EventsTimeline } from "@/components/shared/events-timeline/events-timeline";
import {
QUERY_EDITOR_LANGUAGE,
QueryCodeEditor,
type QueryEditorLanguage,
} from "@/components/shared/query-code-editor";
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";
@@ -85,49 +81,19 @@ function stripCodeFences(code: string): string {
.trim();
}
function resolveNativeIacConfig(providerType: string | undefined): {
label: string;
language: QueryEditorLanguage;
} {
switch (providerType) {
case "aws":
return {
label: "CloudFormation",
language: QUERY_EDITOR_LANGUAGE.YAML,
};
case "azure":
return {
label: "Bicep",
language: QUERY_EDITOR_LANGUAGE.BICEP,
};
case "kubernetes":
return {
label: "Kubernetes Manifest",
language: QUERY_EDITOR_LANGUAGE.YAML,
};
default:
return {
label: "Native IaC",
language: QUERY_EDITOR_LANGUAGE.PLAIN_TEXT,
};
}
}
function renderRemediationCodeBlock({
label,
value,
copyValue,
language = QUERY_EDITOR_LANGUAGE.PLAIN_TEXT,
}: {
label: string;
value: string;
copyValue?: string;
language?: QueryEditorLanguage;
}) {
return (
<QueryCodeEditor
ariaLabel={label}
language={language}
language="plainText"
value={value}
copyValue={copyValue}
editable={false}
@@ -385,7 +351,6 @@ export function ResourceDetailDrawerContent({
? (f?.scan?.id ?? null)
: null;
const regionFilter = searchParams.get("filter[region__in]");
const nativeIacConfig = resolveNativeIacConfig(f?.providerType);
const handleOpenCompliance = async (framework: string) => {
if (!complianceScanId || resolvingFramework) {
@@ -776,7 +741,6 @@ export function ResourceDetailDrawerContent({
<div className="flex flex-col gap-1">
{renderRemediationCodeBlock({
label: "CLI Command",
language: QUERY_EDITOR_LANGUAGE.SHELL,
value: `$ ${stripCodeFences(checkMeta.remediation.code.cli)}`,
copyValue: stripCodeFences(
checkMeta.remediation.code.cli,
@@ -789,7 +753,6 @@ export function ResourceDetailDrawerContent({
<div className="flex flex-col gap-1">
{renderRemediationCodeBlock({
label: "Terraform",
language: QUERY_EDITOR_LANGUAGE.HCL,
value: stripCodeFences(
checkMeta.remediation.code.terraform,
),
@@ -797,11 +760,10 @@ export function ResourceDetailDrawerContent({
</div>
)}
{checkMeta.remediation.code.nativeiac && f && (
{checkMeta.remediation.code.nativeiac && (
<div className="flex flex-col gap-1">
{renderRemediationCodeBlock({
label: nativeIacConfig.label,
language: nativeIacConfig.language,
label: "CloudFormation",
value: stripCodeFences(
checkMeta.remediation.code.nativeiac,
),
@@ -873,7 +835,9 @@ export function ResourceDetailDrawerContent({
className="minimal-scrollbar flex flex-col gap-2 overflow-y-auto"
>
{!f || isNavigating ? (
<LoadingState spinnerClassName="size-5" />
<div className="flex items-center justify-center py-8">
<Spinner className="size-5" />
</div>
) : (
<>
<div className="flex items-center justify-end">

View File

@@ -191,7 +191,7 @@ describe("useResourceDetailDrawer — other findings filtering", () => {
id: "other-1",
checkId: "check-other-1",
checkTitle: "Other 1",
status: "FAIL",
status: "PASS",
severity: "critical",
}),
makeDrawerFinding({
@@ -221,55 +221,6 @@ describe("useResourceDetailDrawer — other findings filtering", () => {
]);
});
it("should exclude non-FAIL findings from otherFindings", async () => {
const resources = [makeResource()];
getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] });
adaptFindingsByResourceResponseMock.mockReturnValue([
makeDrawerFinding({
id: "current",
checkId: "s3_check",
status: "MANUAL",
severity: "informational",
}),
makeDrawerFinding({
id: "other-pass",
checkId: "check-pass",
status: "PASS",
severity: "low",
}),
makeDrawerFinding({
id: "other-manual",
checkId: "check-manual",
status: "MANUAL",
severity: "low",
}),
makeDrawerFinding({
id: "other-fail",
checkId: "check-fail",
status: "FAIL",
severity: "high",
}),
]);
const { result } = renderHook(() =>
useResourceDetailDrawer({
resources,
checkId: "s3_check",
}),
);
await act(async () => {
result.current.openDrawer(0);
await Promise.resolve();
});
expect(result.current.currentFinding?.id).toBe("current");
expect(result.current.otherFindings.map((f) => f.id)).toEqual([
"other-fail",
]);
});
it("should keep isNavigating true for a cached resource long enough to render skeletons", async () => {
vi.useFakeTimers();

View File

@@ -46,7 +46,6 @@ interface UseResourceDetailDrawerOptions {
checkId: string;
totalResourceCount?: number;
onRequestMoreResources?: () => void;
initialIndex?: number | null;
}
interface UseResourceDetailDrawerReturn {
@@ -78,11 +77,10 @@ export function useResourceDetailDrawer({
checkId,
totalResourceCount,
onRequestMoreResources,
initialIndex = null,
}: UseResourceDetailDrawerOptions): UseResourceDetailDrawerReturn {
const [isOpen, setIsOpen] = useState(initialIndex !== null);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [currentIndex, setCurrentIndex] = useState(initialIndex ?? 0);
const [currentIndex, setCurrentIndex] = useState(0);
const [findings, setFindings] = useState<ResourceDrawerFinding[]>([]);
const [isNavigating, setIsNavigating] = useState(false);
@@ -192,22 +190,6 @@ export function useResourceDetailDrawer({
}
};
useEffect(() => {
if (initialIndex === null) {
return;
}
const resource = resources[initialIndex];
if (!resource) {
return;
}
fetchFindings(resource.resourceUid);
// Only initialize once on mount for deep-link/inline entry points.
// User-driven navigations use openDrawer/navigateTo afterwards.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const openDrawer = (index: number) => {
const resource = resources[index];
if (!resource) return;
@@ -269,13 +251,10 @@ export function useResourceDetailDrawer({
const currentFinding =
findings.find((f) => f.checkId === checkId) ?? findings[0] ?? null;
// "Other Findings For This Resource" intentionally shows only FAIL entries,
// while currentFinding (the drilled-down one) can be any status (FAIL, MANUAL, PASS…).
const otherFindings = (
currentFinding
? findings.filter((f) => f.id !== currentFinding.id)
: findings
).filter((f) => f.status === "FAIL");
// All other findings for this resource
const otherFindings = currentFinding
? findings.filter((f) => f.id !== currentFinding.id)
: findings;
return {
isOpen,

View File

@@ -1,11 +0,0 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { getStandaloneFindingColumns } from "@/components/findings/table/column-standalone-findings";
import { FindingProps } from "@/types";
export const ColumnLatestFindings: ColumnDef<FindingProps>[] =
getStandaloneFindingColumns({
includeUpdatedAt: true,
});

View File

@@ -0,0 +1,13 @@
"use client";
import { ColumnDef, RowSelectionState } from "@tanstack/react-table";
import { getColumnFindings } from "@/components/findings/table/column-findings";
import { FindingProps } from "@/types";
const baseColumns: ColumnDef<FindingProps>[] = getColumnFindings(
{} as RowSelectionState,
0,
).filter((column) => column.id !== "select" && column.id !== "actions");
export const ColumnNewFindingsToDate: ColumnDef<FindingProps>[] = baseColumns;

View File

@@ -1,2 +1,2 @@
export * from "./column-latest-findings";
export * from "./column-new-findings-to-date";
export * from "./skeleton-table-new-findings";

View File

@@ -5,28 +5,13 @@ import { useState } from "react";
import { ProviderWizardModal } from "@/components/providers/wizard";
import { Button } from "@/components/shadcn";
interface AddProviderButtonProps {
onOpenWizard?: () => void;
}
export const AddProviderButton = ({ onOpenWizard }: AddProviderButtonProps) => {
export const AddProviderButton = () => {
const [open, setOpen] = useState(false);
const handleOpen = () => {
if (onOpenWizard) {
onOpenWizard();
return;
}
setOpen(true);
};
return (
<>
<Button onClick={handleOpen}>Add Provider</Button>
{!onOpenWizard && (
<ProviderWizardModal open={open} onOpenChange={setOpen} />
)}
<Button onClick={() => setOpen(true)}>Add Provider</Button>
<ProviderWizardModal open={open} onOpenChange={setOpen} />
</>
);
};

View File

@@ -5,7 +5,6 @@ export * from "./forms/delete-form";
export * from "./link-to-scans";
export * from "./muted-findings-config-button";
export * from "./providers-accounts-table";
export * from "./providers-accounts-view";
export * from "./providers-filters";
export * from "./radio-card";
export * from "./radio-group-provider";

View File

@@ -3,10 +3,6 @@
import { RowSelectionState } from "@tanstack/react-table";
import { useEffect, useState } from "react";
import type {
OrgWizardInitialData,
ProviderWizardInitialData,
} from "@/components/providers/wizard/types";
import { DataTable } from "@/components/ui/table";
import { MetaDataProps } from "@/types";
import {
@@ -20,8 +16,6 @@ interface ProvidersAccountsTableProps {
isCloud: boolean;
metadata?: MetaDataProps;
rows: ProvidersTableRow[];
onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void;
onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void;
}
function computeTestableProviderIds(
@@ -54,8 +48,6 @@ export function ProvidersAccountsTable({
isCloud,
metadata,
rows,
onOpenProviderWizard,
onOpenOrganizationWizard,
}: ProvidersAccountsTableProps) {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
@@ -73,8 +65,6 @@ export function ProvidersAccountsTable({
rowSelection,
testableProviderIds,
clearSelection,
onOpenProviderWizard,
onOpenOrganizationWizard,
);
return (

View File

@@ -1,88 +0,0 @@
"use client";
import { useState } from "react";
import { AddProviderButton } from "@/components/providers/add-provider-button";
import { MutedFindingsConfigButton } from "@/components/providers/muted-findings-config-button";
import { ProvidersAccountsTable } from "@/components/providers/providers-accounts-table";
import { ProvidersFilters } from "@/components/providers/providers-filters";
import { ProviderWizardModal } from "@/components/providers/wizard";
import type {
OrgWizardInitialData,
ProviderWizardInitialData,
} from "@/components/providers/wizard/types";
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
import type { ProvidersTableRow } from "@/types/providers-table";
interface ProvidersAccountsViewProps {
isCloud: boolean;
filters: FilterOption[];
metadata?: MetaDataProps;
providers: ProviderProps[];
rows: ProvidersTableRow[];
}
export function ProvidersAccountsView({
isCloud,
filters,
metadata,
providers,
rows,
}: ProvidersAccountsViewProps) {
const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false);
const [providerWizardInitialData, setProviderWizardInitialData] = useState<
ProviderWizardInitialData | undefined
>(undefined);
const [orgWizardInitialData, setOrgWizardInitialData] = useState<
OrgWizardInitialData | undefined
>(undefined);
const openProviderWizard = (initialData?: ProviderWizardInitialData) => {
setOrgWizardInitialData(undefined);
setProviderWizardInitialData(initialData);
setIsProviderWizardOpen(true);
};
const openOrganizationWizard = (initialData: OrgWizardInitialData) => {
setProviderWizardInitialData(undefined);
setOrgWizardInitialData(initialData);
setIsProviderWizardOpen(true);
};
const handleWizardOpenChange = (open: boolean) => {
setIsProviderWizardOpen(open);
if (!open) {
setProviderWizardInitialData(undefined);
setOrgWizardInitialData(undefined);
}
};
return (
<>
<ProvidersFilters
filters={filters}
providers={providers}
actions={
<>
<MutedFindingsConfigButton />
<AddProviderButton onOpenWizard={() => openProviderWizard()} />
</>
}
/>
<ProvidersAccountsTable
isCloud={isCloud}
metadata={metadata}
rows={rows}
onOpenProviderWizard={openProviderWizard}
onOpenOrganizationWizard={openOrganizationWizard}
/>
<ProviderWizardModal
open={isProviderWizardOpen}
onOpenChange={handleWizardOpenChange}
initialData={providerWizardInitialData}
orgInitialData={orgWizardInitialData}
/>
</>
);
}

View File

@@ -9,10 +9,6 @@ import {
ShieldOff,
} from "lucide-react";
import type {
OrgWizardInitialData,
ProviderWizardInitialData,
} from "@/components/providers/wizard/types";
import { Checkbox } from "@/components/shadcn/checkbox/checkbox";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { DateWithTime, EntityInfo } from "@/components/ui/entities";
@@ -112,8 +108,6 @@ export function getColumnProviders(
rowSelection: RowSelectionState,
testableProviderIds: string[],
onClearSelection: () => void,
onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void,
onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void,
): ColumnDef<ProvidersTableRow>[] {
return [
{
@@ -326,8 +320,6 @@ export function getColumnProviders(
isRowSelected={row.getIsSelected()}
testableProviderIds={testableProviderIds}
onClearSelection={onClearSelection}
onOpenProviderWizard={onOpenProviderWizard}
onOpenOrganizationWizard={onOpenOrganizationWizard}
/>
);
},

View File

@@ -3,11 +3,9 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations";
import {
PROVIDERS_GROUP_KIND,
PROVIDERS_ROW_TYPE,
ProvidersTableRow,
} from "@/types/providers-table";
const checkConnectionProviderMock = vi.hoisted(() => vi.fn());
@@ -20,6 +18,10 @@ vi.mock("@/actions/providers/providers", () => ({
checkConnectionProvider: checkConnectionProviderMock,
}));
vi.mock("@/components/providers/wizard", () => ({
ProviderWizardModal: () => null,
}));
vi.mock("../forms/delete-form", () => ({
DeleteForm: () => null,
}));
@@ -42,7 +44,7 @@ vi.mock("@/lib/provider-helpers", () => ({
import { DataTableRowActions } from "./data-table-row-actions";
const createRow = (hasSecret = false) =>
const createRow = () =>
({
original: {
id: "provider-1",
@@ -72,7 +74,7 @@ const createRow = (hasSecret = false) =>
},
relationships: {
secret: {
data: hasSecret ? { id: "secret-1", type: "secrets" } : null,
data: null,
},
provider_groups: {
meta: {
@@ -83,7 +85,7 @@ const createRow = (hasSecret = false) =>
},
groupNames: [],
},
}) as unknown as Row<ProvidersTableRow>;
}) as Row<any>;
const createOrgRow = () =>
({
@@ -115,7 +117,7 @@ const createOrgRow = () =>
},
],
},
}) as unknown as Row<ProvidersTableRow>;
}) as Row<any>;
const createOuRow = () =>
({
@@ -140,21 +142,19 @@ const createOuRow = () =>
},
],
},
}) as unknown as Row<ProvidersTableRow>;
}) as Row<any>;
describe("DataTableRowActions", () => {
it("renders Add Credentials for provider rows without credentials", async () => {
it("renders the exact phase 1 menu actions for provider rows", async () => {
// Given
const user = userEvent.setup();
render(
<DataTableRowActions
row={createRow(false)}
row={createRow()}
hasSelection={false}
isRowSelected={false}
testableProviderIds={[]}
onClearSelection={vi.fn()}
onOpenProviderWizard={vi.fn()}
onOpenOrganizationWizard={vi.fn()}
/>,
);
@@ -163,32 +163,9 @@ describe("DataTableRowActions", () => {
// Then
expect(screen.getByText("Edit Provider Alias")).toBeInTheDocument();
expect(screen.getByText("Add Credentials")).toBeInTheDocument();
expect(screen.getByText("Update Credentials")).toBeInTheDocument();
expect(screen.getByText("Test Connection")).toBeInTheDocument();
expect(screen.getByText("Delete Provider")).toBeInTheDocument();
expect(screen.queryByText("Update Credentials")).not.toBeInTheDocument();
});
it("renders Update Credentials for provider rows with credentials", async () => {
// Given
const user = userEvent.setup();
render(
<DataTableRowActions
row={createRow(true)}
hasSelection={false}
isRowSelected={false}
testableProviderIds={[]}
onClearSelection={vi.fn()}
onOpenProviderWizard={vi.fn()}
onOpenOrganizationWizard={vi.fn()}
/>,
);
// When
await user.click(screen.getByRole("button"));
// Then
expect(screen.getByText("Update Credentials")).toBeInTheDocument();
expect(screen.queryByText("Add Credentials")).not.toBeInTheDocument();
});
@@ -201,8 +178,6 @@ describe("DataTableRowActions", () => {
isRowSelected={false}
testableProviderIds={[]}
onClearSelection={vi.fn()}
onOpenProviderWizard={vi.fn()}
onOpenOrganizationWizard={vi.fn()}
/>,
);
@@ -224,8 +199,6 @@ describe("DataTableRowActions", () => {
isRowSelected={false}
testableProviderIds={[]}
onClearSelection={vi.fn()}
onOpenProviderWizard={vi.fn()}
onOpenOrganizationWizard={vi.fn()}
/>,
);
@@ -247,8 +220,6 @@ describe("DataTableRowActions", () => {
isRowSelected={false}
testableProviderIds={[]}
onClearSelection={vi.fn()}
onOpenProviderWizard={vi.fn()}
onOpenOrganizationWizard={vi.fn()}
/>,
);
@@ -267,8 +238,6 @@ describe("DataTableRowActions", () => {
isRowSelected={false}
testableProviderIds={["provider-child-1", "provider-standalone"]}
onClearSelection={vi.fn()}
onOpenProviderWizard={vi.fn()}
onOpenOrganizationWizard={vi.fn()}
/>,
);
@@ -288,8 +257,6 @@ describe("DataTableRowActions", () => {
isRowSelected={false}
testableProviderIds={["provider-ou-child-1", "provider-standalone"]}
onClearSelection={vi.fn()}
onOpenProviderWizard={vi.fn()}
onOpenOrganizationWizard={vi.fn()}
/>,
);
@@ -309,8 +276,6 @@ describe("DataTableRowActions", () => {
isRowSelected={false}
testableProviderIds={[]}
onClearSelection={vi.fn()}
onOpenProviderWizard={vi.fn()}
onOpenOrganizationWizard={vi.fn()}
/>,
);
@@ -321,68 +286,4 @@ describe("DataTableRowActions", () => {
).not.toBeInTheDocument();
expect(screen.queryByText("Update Credentials")).not.toBeInTheDocument();
});
it("opens the shared provider wizard when provider credentials action is selected", async () => {
// Given
const user = userEvent.setup();
const onOpenProviderWizard = vi.fn();
render(
<DataTableRowActions
row={createRow(true)}
hasSelection={false}
isRowSelected={false}
testableProviderIds={[]}
onClearSelection={vi.fn()}
onOpenProviderWizard={onOpenProviderWizard}
onOpenOrganizationWizard={vi.fn()}
/>,
);
// When
await user.click(screen.getByRole("button"));
await user.click(screen.getByText("Update Credentials"));
// Then
expect(onOpenProviderWizard).toHaveBeenCalledWith({
providerId: "provider-1",
providerType: "aws",
providerUid: "111111111111",
providerAlias: "AWS App Account",
secretId: "secret-1",
mode: "update",
});
});
it("opens the shared organization wizard when org credentials action is selected", async () => {
// Given
const user = userEvent.setup();
const onOpenOrganizationWizard = vi.fn();
render(
<DataTableRowActions
row={createOrgRow()}
hasSelection={false}
isRowSelected={false}
testableProviderIds={[]}
onClearSelection={vi.fn()}
onOpenProviderWizard={vi.fn()}
onOpenOrganizationWizard={onOpenOrganizationWizard}
/>,
);
// When
await user.click(screen.getByRole("button"));
await user.click(screen.getByText("Update Credentials"));
// Then
expect(onOpenOrganizationWizard).toHaveBeenCalledWith({
organizationId: "org-1",
organizationName: "My AWS Organization",
externalId: "o-abc123def4",
targetStep: ORG_WIZARD_STEP.SETUP,
targetPhase: ORG_SETUP_PHASE.ACCESS,
intent: "edit-credentials",
});
});
});

View File

@@ -6,10 +6,10 @@ import { useState } from "react";
import { updateOrganizationName } from "@/actions/organizations/organizations";
import { updateProvider } from "@/actions/providers";
import { ProviderWizardModal } from "@/components/providers/wizard";
import {
ORG_WIZARD_INTENT,
OrgWizardInitialData,
ProviderWizardInitialData,
} from "@/components/providers/wizard/types";
import {
ActionDropdown,
@@ -44,8 +44,6 @@ interface DataTableRowActionsProps {
testableProviderIds: string[];
/** Callback to clear the row selection after bulk operation */
onClearSelection: () => void;
onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void;
onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void;
}
function collectTestableChildProviderIds(rows: ProvidersTableRow[]): string[] {
@@ -71,7 +69,6 @@ interface OrgGroupDropdownActionsProps {
onClearSelection: () => void;
onBulkTest: (ids: string[]) => Promise<void>;
onTestChildConnections: () => Promise<void>;
onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void;
}
function OrgGroupDropdownActions({
@@ -83,10 +80,12 @@ function OrgGroupDropdownActions({
onClearSelection,
onBulkTest,
onTestChildConnections,
onOpenOrganizationWizard,
}: OrgGroupDropdownActionsProps) {
const [isDeleteOrgOpen, setIsDeleteOrgOpen] = useState(false);
const [isEditNameOpen, setIsEditNameOpen] = useState(false);
const [isOrgWizardOpen, setIsOrgWizardOpen] = useState(false);
const [orgWizardData, setOrgWizardData] =
useState<OrgWizardInitialData | null>(null);
const isOrgKind = rowData.groupKind === PROVIDERS_GROUP_KIND.ORGANIZATION;
const testIds = hasSelection ? testableProviderIds : childTestableIds;
@@ -98,7 +97,7 @@ function OrgGroupDropdownActions({
targetPhase: OrgWizardInitialData["targetPhase"],
intent?: OrgWizardInitialData["intent"],
) => {
onOpenOrganizationWizard({
setOrgWizardData({
organizationId: rowData.id,
organizationName: rowData.name,
externalId: rowData.externalId ?? "",
@@ -106,25 +105,33 @@ function OrgGroupDropdownActions({
targetPhase,
intent,
});
setIsOrgWizardOpen(true);
};
return (
<>
{isOrgKind && (
<Modal
open={isEditNameOpen}
onOpenChange={setIsEditNameOpen}
title="Edit Organization Name"
>
<EditNameForm
currentValue={rowData.name}
label="Name"
successMessage="The organization name was updated successfully."
helperText="If left blank, Prowler will use the name stored in AWS."
setIsOpen={setIsEditNameOpen}
onSave={(name) => updateOrganizationName(rowData.id, name)}
<>
<Modal
open={isEditNameOpen}
onOpenChange={setIsEditNameOpen}
title="Edit Organization Name"
>
<EditNameForm
currentValue={rowData.name}
label="Name"
successMessage="The organization name was updated successfully."
helperText="If left blank, Prowler will use the name stored in AWS."
setIsOpen={setIsEditNameOpen}
onSave={(name) => updateOrganizationName(rowData.id, name)}
/>
</Modal>
<ProviderWizardModal
open={isOrgWizardOpen}
onOpenChange={setIsOrgWizardOpen}
orgInitialData={orgWizardData ?? undefined}
/>
</Modal>
</>
)}
<Modal
open={isDeleteOrgOpen}
@@ -198,11 +205,10 @@ export function DataTableRowActions({
isRowSelected,
testableProviderIds,
onClearSelection,
onOpenProviderWizard,
onOpenOrganizationWizard,
}: DataTableRowActionsProps) {
const [isEditOpen, setIsEditOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isWizardOpen, setIsWizardOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { toast } = useToast();
@@ -317,7 +323,6 @@ export function DataTableRowActions({
onClearSelection={onClearSelection}
onBulkTest={handleBulkTest}
onTestChildConnections={handleTestChildConnections}
onOpenOrganizationWizard={onOpenOrganizationWizard}
/>
);
}
@@ -364,6 +369,21 @@ export function DataTableRowActions({
<DeleteForm providerId={providerId} setIsOpen={setIsDeleteOpen} />
)}
</Modal>
<ProviderWizardModal
open={isWizardOpen}
onOpenChange={setIsWizardOpen}
initialData={{
providerId,
providerType,
providerUid,
providerAlias,
secretId: providerSecretId,
mode: providerSecretId
? PROVIDER_WIZARD_MODE.UPDATE
: PROVIDER_WIZARD_MODE.ADD,
}}
/>
<div className="relative flex items-center justify-end gap-2">
<ActionDropdown>
<ActionDropdownItem
@@ -373,19 +393,8 @@ export function DataTableRowActions({
/>
<ActionDropdownItem
icon={<KeyRound />}
label={hasSecret ? "Update Credentials" : "Add Credentials"}
onSelect={() =>
onOpenProviderWizard({
providerId,
providerType,
providerUid,
providerAlias,
secretId: providerSecretId,
mode: providerSecretId
? PROVIDER_WIZARD_MODE.UPDATE
: PROVIDER_WIZARD_MODE.ADD,
})
}
label="Update Credentials"
onSelect={() => setIsWizardOpen(true)}
/>
<ActionDropdownItem
icon={<Rocket />}

View File

@@ -9,19 +9,8 @@ import {
PROVIDER_WIZARD_STEP,
} from "@/types/provider-wizard";
import type { ProviderWizardInitialData } from "../types";
import { useProviderWizardController } from "./use-provider-wizard-controller";
const { refreshMock } = vi.hoisted(() => ({
refreshMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: refreshMock,
}),
}));
vi.mock("next-auth/react", () => ({
useSession: () => ({
data: null,
@@ -32,33 +21,12 @@ vi.mock("next-auth/react", () => ({
describe("useProviderWizardController", () => {
beforeEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
sessionStorage.clear();
localStorage.clear();
useProviderWizardStore.getState().reset();
useOrgSetupStore.getState().reset();
});
it("refreshes providers data when the wizard closes", () => {
// Given
const onOpenChange = vi.fn();
const { result } = renderHook(() =>
useProviderWizardController({
open: true,
onOpenChange,
}),
);
// When
act(() => {
result.current.handleClose();
});
// Then
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(refreshMock).toHaveBeenCalledTimes(1);
});
it("hydrates update mode when initial data is provided", async () => {
// Given
const onOpenChange = vi.fn();
@@ -153,7 +121,7 @@ describe("useProviderWizardController", () => {
expect(onOpenChange).not.toHaveBeenCalled();
});
it("moves to launch step after a successful connection test in update mode", async () => {
it("closes the modal after a successful connection test in update mode", async () => {
// Given
const onOpenChange = vi.fn();
const { result } = renderHook(() =>
@@ -181,9 +149,8 @@ describe("useProviderWizardController", () => {
result.current.handleTestSuccess();
});
// Then
expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.LAUNCH);
expect(onOpenChange).not.toHaveBeenCalled();
// Then — update mode should close the modal, not advance to launch
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("does not override launch footer config in the controller", () => {
@@ -248,7 +215,14 @@ describe("useProviderWizardController", () => {
initialData,
}: {
open: boolean;
initialData?: ProviderWizardInitialData;
initialData?: {
providerId: string;
providerType: "gcp";
providerUid: string;
providerAlias: string;
secretId: string | null;
mode: "add" | "update";
};
}) =>
useProviderWizardController({
open,

View File

@@ -1,6 +1,5 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { DOCS_URLS, getProviderHelpText } from "@/lib/external-urls";
@@ -58,7 +57,6 @@ export function useProviderWizardController({
initialData,
orgInitialData,
}: UseProviderWizardControllerProps) {
const router = useRouter();
const initialProviderId = initialData?.providerId ?? null;
const initialProviderType = initialData?.providerType ?? null;
const initialProviderUid = initialData?.providerUid ?? null;
@@ -185,7 +183,6 @@ export function useProviderWizardController({
setProviderTypeHint(null);
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
onOpenChange(false);
router.refresh();
};
const handleDialogOpenChange = (nextOpen: boolean) => {
@@ -197,6 +194,10 @@ export function useProviderWizardController({
};
const handleTestSuccess = () => {
if (mode === PROVIDER_WIZARD_MODE.UPDATE) {
handleClose();
return;
}
setCurrentStep(PROVIDER_WIZARD_STEP.LAUNCH);
};

View File

@@ -1,16 +0,0 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("resource details sheet", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const filePath = path.join(currentDir, "resource-details-sheet.tsx");
const source = readFileSync(filePath, "utf8");
it("forces a remount when switching resources so local drawer state resets without effects", () => {
expect(source).toContain("key={resource.id}");
expect(source).toContain("resourceDetails={resource}");
});
});

View File

@@ -36,9 +36,7 @@ export const ResourceDetailsSheet = ({
<X className="size-4" />
<span className="sr-only">Close</span>
</DrawerClose>
{open && (
<ResourceDetailContent key={resource.id} resourceDetails={resource} />
)}
{open && <ResourceDetailContent resourceDetails={resource} />}
</DrawerContent>
</Drawer>
);

View File

@@ -0,0 +1,76 @@
import React from "react";
export const SkeletonFindingDetails = () => {
return (
<div className="dark:bg-prowler-blue-400 flex animate-pulse flex-col gap-6 rounded-lg p-4 shadow">
{/* Header */}
<div className="flex items-center justify-between">
<div className="bg-default-200 h-6 w-2/3 rounded" />
<div className="flex items-center gap-x-4">
<div className="bg-default-200 h-5 w-6 rounded-full" />
<div className="bg-default-200 h-6 w-20 rounded" />
</div>
</div>
{/* Metadata Section */}
<div className="flex flex-wrap gap-4">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="flex flex-col gap-1">
<div className="bg-default-200 h-4 w-20 rounded" />
<div className="bg-default-200 h-5 w-40 rounded" />
</div>
))}
</div>
{/* InfoField Blocks */}
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="flex flex-col gap-2">
<div className="bg-default-200 h-4 w-28 rounded" />
<div className="bg-default-200 h-5 w-full rounded" />
</div>
))}
{/* Risk and Description Sections */}
<div className="flex flex-col gap-2">
<div className="bg-default-200 h-4 w-28 rounded" />
<div className="bg-default-200 h-16 w-full rounded" />
</div>
<div className="bg-default-200 h-4 w-36 rounded" />
<div className="flex flex-col gap-2">
<div className="bg-default-200 h-4 w-24 rounded" />
<div className="bg-default-200 h-5 w-2/3 rounded" />
<div className="bg-default-200 h-4 w-24 rounded" />
</div>
<div className="flex flex-col gap-2">
<div className="bg-default-200 h-4 w-28 rounded" />
<div className="bg-default-200 h-10 w-full rounded" />
</div>
{/* Additional Resources */}
<div className="flex flex-col gap-2">
<div className="bg-default-200 h-4 w-36 rounded" />
<div className="bg-default-200 h-5 w-32 rounded" />
</div>
{/* Categories */}
<div className="flex flex-col gap-2">
<div className="bg-default-200 h-4 w-24 rounded" />
<div className="bg-default-200 h-5 w-1/3 rounded" />
</div>
{/* Provider Info Section */}
<div className="mt-4 flex items-center gap-2">
<div className="bg-default-200 relative h-8 w-8 rounded-full">
<div className="bg-default-300 absolute top-0 right-0 h-2 w-2 rounded-full" />
</div>
<div className="flex max-w-[120px] flex-col gap-1">
<div className="bg-default-200 h-4 w-full rounded" />
<div className="bg-default-200 h-4 w-16 rounded" />
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,16 @@
import React from "react";
export const SkeletonFindingSummary = () => {
return (
<div className="dark:bg-prowler-blue-400 flex animate-pulse flex-col gap-4 rounded-lg p-4 shadow">
<div className="flex items-center justify-between gap-4">
<div className="bg-default-200 h-5 w-1/3 rounded" />
<div className="flex items-center gap-2">
<div className="bg-default-200 h-5 w-16 rounded" />
<div className="bg-default-200 h-5 w-16 rounded" />
<div className="bg-default-200 h-5 w-5 rounded-full" />
</div>
</div>
</div>
);
};

View File

@@ -1,4 +1,5 @@
export * from "../skeleton/skeleton-table-resources";
export * from "./column-resources";
export * from "./resource-detail";
export * from "./resource-findings-columns";
export * from "./resources-table-with-selection";

View File

@@ -1,29 +0,0 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("resource detail content", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const filePath = path.join(currentDir, "resource-detail-content.tsx");
const source = readFileSync(filePath, "utf8");
it("renders the new finding detail drawer flow instead of the legacy finding detail component", () => {
expect(source).toContain("FindingDetailDrawer");
expect(source).not.toContain("FindingDetail findingDetails");
});
it("loads the drawer bootstrap data through a single shared resource action", () => {
expect(source).toContain("useResourceDrawerBootstrap");
expect(source).not.toContain("getResourceDrawerData");
expect(source).not.toContain("listOrganizationsSafe");
expect(source).not.toContain("getResourceById");
expect(source).not.toContain("getLatestFindings");
});
it("does not import useEffect directly and relies on hooks/keyed remounts instead", () => {
expect(source).not.toContain("useEffect");
expect(source).not.toContain("useEffect(");
});
});

View File

@@ -8,12 +8,16 @@ import {
CornerDownRight,
ExternalLink,
Link,
Loader2,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
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 { FindingDetailDrawer } from "@/components/findings/table";
import { FindingDetail } from "@/components/findings/table/finding-detail";
import {
Card,
Tabs,
@@ -28,23 +32,56 @@ import {
InfoField,
InfoTooltip,
} 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 { BreadcrumbNavigation, CustomBreadcrumbItem } from "@/components/ui";
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 { ProviderType, ResourceProps } from "@/types";
import {
FindingProps,
MetaDataProps,
ProviderType,
ResourceProps,
} from "@/types";
import { OrganizationResource } from "@/types/organizations";
import {
getResourceFindingsColumns,
ResourceFinding,
} from "./resource-findings-columns";
import { useFindingDetails } from "./use-finding-details";
import { useResourceDrawerBootstrap } from "./use-resource-drawer-bootstrap";
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 : "-";
@@ -109,16 +146,27 @@ interface ResourceDetailContentProps {
export const ResourceDetailContent = ({
resourceDetails,
}: ResourceDetailContentProps) => {
const [findingsData, setFindingsData] = useState<ResourceFinding[]>([]);
const [findingsMetadata, setFindingsMetadata] =
useState<MetaDataProps | null>(null);
const [resourceTags, setResourceTags] = useState<Record<string, string>>({});
const [findingsLoading, setFindingsLoading] = useState(true);
const [hasInitiallyLoaded, setHasInitiallyLoaded] = useState(false);
const [findingsReloadNonce, setFindingsReloadNonce] = useState(0);
const [selectedFindingId, setSelectedFindingId] = useState<string | null>(
null,
);
const [findingDetails, setFindingDetails] = useState<FindingProps | null>(
null,
);
const [findingDetailLoading, setFindingDetailLoading] = useState(false);
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 findingFetchRef = useRef<AbortController | null>(null);
const router = useRouter();
const resource = resourceDetails;
@@ -126,29 +174,22 @@ export const ResourceDetailContent = ({
const attributes = resource.attributes;
const providerData = resource.relationships.provider.data.attributes;
const providerId = resource.relationships.provider.data.id;
const {
findingsData,
findingsMetadata,
findingsLoading,
hasInitiallyLoaded,
providerOrg,
resourceTags,
} = useResourceDrawerBootstrap({
resourceId,
resourceUid: attributes.uid,
const providerOrg = useProviderOrganization(
providerId,
providerType: providerData.provider,
currentPage,
pageSize,
searchQuery,
findingsReloadNonce,
});
const {
findingDetails,
findingDetailLoading,
navigateToFinding: loadFindingDetails,
resetFindingDetails,
} = useFindingDetails();
providerData.provider,
);
// Reset to overview tab when switching resources
useEffect(() => {
setActiveTab("findings");
}, [resourceId]);
// Cleanup abort controller on unmount
useEffect(() => {
return () => {
findingFetchRef.current?.abort();
};
}, []);
const copyResourceUrl = () => {
const url = `${window.location.origin}/resources?resourceId=${resourceId}`;
@@ -161,14 +202,119 @@ export const ResourceDetailContent = ({
setTimeout(() => setMetadataCopied(false), 2000);
};
// Load resource tags on mount
useEffect(() => {
const loadResourceTags = async () => {
try {
const resourceData = await getResourceById(resourceId, {
fields: ["tags"],
});
if (resourceData?.data) {
setResourceTags(resourceData.data.attributes.tags || {});
}
} catch (err) {
console.error("Error loading resource tags:", err);
setResourceTags({});
}
};
if (resourceId) {
loadResourceTags();
}
}, [resourceId]);
// Load findings with server-side pagination and search
useEffect(() => {
const loadFindings = async () => {
setFindingsLoading(true);
try {
const findingsResponse = await getLatestFindings({
page: currentPage,
pageSize,
query: searchQuery,
sort: "severity,-inserted_at",
filters: {
"filter[resource_uid]": attributes.uid,
"filter[status]": "FAIL",
},
});
if (findingsResponse?.data) {
setFindingsMetadata(findingsResponse.meta || null);
setFindingsData(findingsResponse.data as ResourceFinding[]);
} else {
setFindingsData([]);
setFindingsMetadata(null);
}
} catch (err) {
console.error("Error loading findings:", err);
setFindingsData([]);
setFindingsMetadata(null);
} finally {
setFindingsLoading(false);
setHasInitiallyLoaded(true);
}
};
if (attributes.uid) {
loadFindings();
}
}, [attributes.uid, currentPage, pageSize, searchQuery, findingsReloadNonce]);
const navigateToFinding = async (findingId: string) => {
if (findingFetchRef.current) {
findingFetchRef.current.abort();
}
findingFetchRef.current = new AbortController();
setSelectedFindingId(findingId);
await loadFindingDetails(findingId);
setFindingDetailLoading(true);
try {
const findingData = await getFindingById(
findingId,
"resources,scan.provider",
);
if (findingFetchRef.current?.signal.aborted) {
return;
}
if (findingData?.data) {
const resourceDict = createDict("resources", findingData);
const scanDict = createDict("scans", findingData);
const providerDict = createDict("providers", findingData);
const finding = findingData.data;
const scan = scanDict[finding.relationships?.scan?.data?.id];
const foundResource =
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
const provider = providerDict[scan?.relationships?.provider?.data?.id];
const expandedFinding = {
...finding,
relationships: { scan, resource: foundResource, provider },
};
setFindingDetails(expandedFinding);
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
return;
}
console.error("Error fetching finding:", error);
} finally {
if (!findingFetchRef.current?.signal.aborted) {
setFindingDetailLoading(false);
}
}
};
const handleBackToResource = () => {
setSelectedFindingId(null);
resetFindingDetails();
setFindingDetails(null);
setFindingDetailLoading(false);
};
const handleMuteComplete = (_findingIds?: string[]) => {
@@ -186,6 +332,11 @@ export const ResourceDetailContent = ({
(f) => !f.attributes.muted,
).length;
// Reset selection when page changes
useEffect(() => {
setRowSelection({});
}, [currentPage, pageSize]);
const totalFindings = findingsMetadata?.pagination?.count || 0;
const getRowCanSelect = (row: Row<ResourceFinding>): boolean =>
@@ -245,16 +396,14 @@ export const ResourceDetailContent = ({
/>
{findingDetailLoading ? (
<LoadingState label="Loading finding details..." />
<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 finding details...
</p>
</div>
) : (
findingDetails && (
<FindingDetailDrawer
key={findingDetails.id}
finding={findingDetails}
inline
onMuteComplete={handleMuteComplete}
/>
)
findingDetails && <FindingDetail findingDetails={findingDetails} />
)}
</div>
);
@@ -394,7 +543,12 @@ export const ResourceDetailContent = ({
<div className="minimal-scrollbar min-h-0 flex-1 overflow-y-auto">
<TabsContent value="findings" className="flex flex-col gap-4">
{findingsLoading && !hasInitiallyLoaded ? (
<LoadingState label="Loading findings..." />
<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
@@ -409,21 +563,13 @@ export const ResourceDetailContent = ({
getRowCanSelect={getRowCanSelect}
controlledSearch={searchQuery}
onSearchChange={(value) => {
setRowSelection({});
setSearchQuery(value);
setCurrentPage(1);
}}
controlledPage={currentPage}
controlledPageSize={pageSize}
onPageChange={(page) => {
setRowSelection({});
setCurrentPage(page);
}}
onPageSizeChange={(size) => {
setRowSelection({});
setCurrentPage(1);
setPageSize(size);
}}
onPageChange={setCurrentPage}
onPageSizeChange={setPageSize}
isLoading={findingsLoading}
/>
{selectedFindingIds.length > 0 && (
@@ -509,12 +655,10 @@ export const ResourceDetailContent = ({
</TabsContent>
<TabsContent value="events" className="flex flex-col gap-4">
{activeTab === "events" && (
<EventsTimeline
resourceId={resourceId}
isAwsProvider={providerData.provider === "aws"}
/>
)}
<EventsTimeline
resourceId={resourceId}
isAwsProvider={providerData.provider === "aws"}
/>
</TabsContent>
</div>
</Tabs>

View File

@@ -0,0 +1,85 @@
"use client";
import { X } from "lucide-react";
import type { ReactNode } from "react";
import { useState } from "react";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/shadcn";
import { ResourceProps } from "@/types";
import { ResourceDetailContent } from "./resource-detail-content";
interface ResourceDetailProps {
resourceDetails: ResourceProps;
trigger?: ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}
/**
* Lightweight wrapper component for resource details.
*
* When used with a trigger (table rows), this component only renders the Drawer shell
* and trigger. The heavy ResourceDetailContent is only mounted when the drawer is open,
* preventing unnecessary state initialization and data fetching for closed drawers.
*
* When used without a trigger (inline mode from ResourceDetailsSheet), it renders
* the content directly since it's already visible.
*/
export const ResourceDetail = ({
resourceDetails,
trigger,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
}: ResourceDetailProps) => {
// Track internal open state for uncontrolled drawer (when using trigger)
const [internalOpen, setInternalOpen] = useState(defaultOpen);
// Determine actual open state
const isOpen = controlledOpen ?? internalOpen;
// Handle open state changes
const handleOpenChange = (newOpen: boolean) => {
setInternalOpen(newOpen);
onOpenChange?.(newOpen);
};
// If no trigger, render content directly (inline mode for ResourceDetailsSheet)
if (!trigger) {
return <ResourceDetailContent resourceDetails={resourceDetails} />;
}
// With trigger, wrap in Drawer - content only mounts when open (lazy loading)
return (
<Drawer
direction="right"
open={isOpen}
defaultOpen={defaultOpen}
onOpenChange={handleOpenChange}
>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="minimal-scrollbar 3xl:w-1/3 h-full w-full overflow-x-hidden overflow-y-auto p-6 outline-none md:w-1/2 md:max-w-none">
<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>
{/* Content only renders when drawer is open - this is the key optimization */}
{isOpen && <ResourceDetailContent resourceDetails={resourceDetails} />}
</DrawerContent>
</Drawer>
);
};

View File

@@ -1,72 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { getFindingById } from "@/actions/findings";
import { expandFindingWithRelationships } from "@/lib/finding-detail";
import { FindingProps } from "@/types";
interface UseFindingDetailsReturn {
findingDetails: FindingProps | null;
findingDetailLoading: boolean;
navigateToFinding: (findingId: string) => Promise<void>;
resetFindingDetails: () => void;
}
export function useFindingDetails(): UseFindingDetailsReturn {
const [findingDetails, setFindingDetails] = useState<FindingProps | null>(
null,
);
const [findingDetailLoading, setFindingDetailLoading] = useState(false);
const findingFetchRef = useRef<AbortController | null>(null);
useEffect(() => {
return () => {
findingFetchRef.current?.abort();
};
}, []);
const navigateToFinding = async (findingId: string) => {
if (findingFetchRef.current) {
findingFetchRef.current.abort();
}
findingFetchRef.current = new AbortController();
setFindingDetailLoading(true);
try {
const findingData = await getFindingById(
findingId,
"resources,scan.provider",
);
if (findingFetchRef.current?.signal.aborted) {
return;
}
if (findingData?.data) {
setFindingDetails(expandFindingWithRelationships(findingData));
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
return;
}
console.error("Error fetching finding:", error);
} finally {
if (!findingFetchRef.current?.signal.aborted) {
setFindingDetailLoading(false);
}
}
};
const resetFindingDetails = () => {
setFindingDetails(null);
setFindingDetailLoading(false);
};
return {
findingDetails,
findingDetailLoading,
navigateToFinding,
resetFindingDetails,
};
}

View File

@@ -1,115 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { getResourceDrawerData } from "@/actions/resources";
import { MetaDataProps } from "@/types";
import { OrganizationResource } from "@/types/organizations";
import { ResourceFinding } from "./resource-findings-columns";
interface UseResourceDrawerBootstrapOptions {
resourceId: string;
resourceUid: string;
providerId: string;
providerType: string;
currentPage: number;
pageSize: number;
searchQuery: string;
findingsReloadNonce: number;
}
interface UseResourceDrawerBootstrapReturn {
findingsData: ResourceFinding[];
findingsMetadata: MetaDataProps | null;
findingsLoading: boolean;
hasInitiallyLoaded: boolean;
providerOrg: OrganizationResource | null;
resourceTags: Record<string, string>;
}
export function useResourceDrawerBootstrap({
resourceId,
resourceUid,
providerId,
providerType,
currentPage,
pageSize,
searchQuery,
findingsReloadNonce,
}: UseResourceDrawerBootstrapOptions): UseResourceDrawerBootstrapReturn {
const [findingsData, setFindingsData] = useState<ResourceFinding[]>([]);
const [findingsMetadata, setFindingsMetadata] =
useState<MetaDataProps | null>(null);
const [resourceTags, setResourceTags] = useState<Record<string, string>>({});
const [findingsLoading, setFindingsLoading] = useState(true);
const [hasInitiallyLoaded, setHasInitiallyLoaded] = useState(false);
const [providerOrg, setProviderOrg] = useState<OrganizationResource | null>(
null,
);
useEffect(() => {
let cancelled = false;
const loadResourceDrawerData = async () => {
setFindingsLoading(true);
try {
const drawerData = await getResourceDrawerData({
resourceId,
resourceUid,
providerId,
providerType,
page: currentPage,
pageSize,
query: searchQuery,
});
if (cancelled) return;
setResourceTags(drawerData.resourceTags);
setProviderOrg(drawerData.providerOrg);
setFindingsMetadata(drawerData.findingsMeta as MetaDataProps | null);
setFindingsData(drawerData.findings as ResourceFinding[]);
} catch (error) {
if (cancelled) return;
console.error("Error loading resource drawer data:", error);
setResourceTags({});
setProviderOrg(null);
setFindingsData([]);
setFindingsMetadata(null);
} finally {
if (!cancelled) {
setFindingsLoading(false);
setHasInitiallyLoaded(true);
}
}
};
if (resourceUid) {
loadResourceDrawerData();
}
return () => {
cancelled = true;
};
}, [
currentPage,
findingsReloadNonce,
pageSize,
providerId,
providerType,
resourceId,
resourceUid,
searchQuery,
]);
return {
findingsData,
findingsMetadata,
findingsLoading,
hasInitiallyLoaded,
providerOrg,
resourceTags,
};
}

View File

@@ -1,36 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { LoadingState } from "./loading-state";
describe("LoadingState", () => {
it("renders a spinner with the default size", () => {
const { container } = render(<LoadingState />);
const svg = container.querySelector("svg");
expect(svg).toHaveAttribute("aria-label", "Loading");
expect(
svg?.className.baseVal ?? svg?.getAttribute("class") ?? "",
).toContain("size-6");
});
it("does not render a label when none is provided", () => {
render(<LoadingState />);
expect(screen.queryByText(/loading/i, { selector: "span" })).toBeNull();
});
it("renders the label when provided", () => {
render(<LoadingState label="Loading findings..." />);
expect(screen.getByText("Loading findings...")).toBeInTheDocument();
});
it("forwards spinnerClassName to the Spinner so callers can override the size", () => {
const { container } = render(<LoadingState spinnerClassName="size-5" />);
const svg = container.querySelector("svg");
expect(svg?.getAttribute("class") ?? "").toContain("size-5");
});
it("forwards className to the wrapper element", () => {
const { container } = render(<LoadingState className="custom-wrapper" />);
expect(container.firstChild).toHaveClass("custom-wrapper");
});
});

View File

@@ -1,28 +0,0 @@
"use client";
import { cn } from "@/lib/utils";
import { Spinner } from "./spinner";
interface LoadingStateProps {
label?: string;
className?: string;
spinnerClassName?: string;
}
export function LoadingState({
label,
className,
spinnerClassName,
}: LoadingStateProps) {
return (
<div
className={cn("flex items-center justify-center gap-2 py-8", className)}
>
<Spinner className={cn("size-6", spinnerClassName)} />
{label && (
<span className="text-text-neutral-tertiary text-sm">{label}</span>
)}
</div>
);
}

View File

@@ -1,17 +0,0 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("events timeline", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const filePath = path.join(currentDir, "events-timeline.tsx");
const source = readFileSync(filePath, "utf8");
it("delegates resource event loading to a dedicated hook instead of using useEffect in the component", () => {
expect(source).toContain("useResourceEventsTimeline");
expect(source).not.toContain("getResourceEvents");
expect(source).not.toContain("useEffect(");
});
});

Some files were not shown because too many files have changed in this diff Show More