Compare commits

..

2 Commits

Author SHA1 Message Date
Pablo F.G
dd66c95cea docs(ui): add PROWLER-1383 changelog entry for attack path scan selector fix
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:48:07 +02:00
Pablo F.G
645424242a fix(ui): make attack path scan selector indicate and allow selecting latest available scan
Button labels now reflect graph_data_ready instead of scan execution
state, disabled buttons show a tooltip explaining why selection is
unavailable, and the green dot visual cue appears on all scan states
when graph data is ready.

Closes PROWLER-1383

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:46:36 +02:00
98 changed files with 2526 additions and 3855 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

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

@@ -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,12 @@ 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)
- Attack Path scan selector now labels buttons based on `graph_data_ready` instead of scan state, shows tooltip on disabled buttons, and displays green dot on all scan states when graph data is available [(#10694)](https://github.com/prowler-cloud/prowler/pull/10694)
---
@@ -51,8 +41,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

@@ -28,12 +28,29 @@ vi.mock("@/components/shadcn/tooltip", () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({
children,
asChild: _asChild,
...props
}: {
children: ReactNode;
asChild?: boolean;
}) => <>{children}</>,
}) => <div {...props}>{children}</div>,
TooltipContent: ({ children }: { children: ReactNode }) => (
<span data-testid="tooltip-content">{children}</span>
<div role="tooltip">{children}</div>
),
}));
vi.mock("./scan-status-badge", () => ({
ScanStatusBadge: ({
status,
graphDataReady,
}: {
status: string;
graphDataReady?: boolean;
}) => (
<span>
{status}
{graphDataReady && " (graph ready)"}
</span>
),
}));
@@ -214,19 +231,17 @@ describe("ScanListTable", () => {
const button = screen.getByRole("button", { name: "Select scan" });
expect(button).toBeDisabled();
expect(button).toHaveTextContent("Failed");
expect(button).toHaveTextContent("Unavailable");
});
it("shows 'Scheduled' label for a scheduled scan without graph data", () => {
// PROWLER-1383: Button label based on graph_data_ready instead of scan state
it("shows 'Unavailable' for scheduled scan when graph data is not ready", () => {
const scheduledScan: AttackPathScan = {
...createScan(1),
attributes: {
...createScan(1).attributes,
state: "scheduled",
progress: 0,
graph_data_ready: false,
completed_at: null,
duration: null,
},
};
@@ -234,10 +249,10 @@ describe("ScanListTable", () => {
const button = screen.getByRole("button", { name: "Select scan" });
expect(button).toBeDisabled();
expect(button).toHaveTextContent("Scheduled");
expect(button).toHaveTextContent("Unavailable");
});
it("shows 'Running...' label for an executing scan without graph data", () => {
it("shows 'Unavailable' for executing scan when graph data is not ready", () => {
const executingScan: AttackPathScan = {
...createScan(1),
attributes: {
@@ -245,8 +260,6 @@ describe("ScanListTable", () => {
state: "executing",
progress: 45,
graph_data_ready: false,
completed_at: null,
duration: null,
},
};
@@ -254,55 +267,78 @@ describe("ScanListTable", () => {
const button = screen.getByRole("button", { name: "Select scan" });
expect(button).toBeDisabled();
expect(button).toHaveTextContent("Running...");
expect(button).toHaveTextContent("Unavailable");
});
it("enables Select for a scheduled scan when graph data is ready from a previous cycle", async () => {
// PROWLER-1383: Enable Select on scheduled/executing scans with graph data from previous cycle
it("enables 'Select' for executing scan when graph data is ready from previous cycle", async () => {
const user = userEvent.setup();
const scheduledWithGraph: AttackPathScan = {
const executingScan: AttackPathScan = {
...createScan(1),
attributes: {
...createScan(1).attributes,
state: "scheduled",
progress: 0,
state: "executing",
progress: 30,
graph_data_ready: true,
},
};
render(<ScanListTable scans={[scheduledWithGraph]} />);
render(<ScanListTable scans={[executingScan]} />);
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 = {
it("enables 'Select' for scheduled scan when graph data is ready from previous cycle", async () => {
const user = userEvent.setup();
const scheduledScan: AttackPathScan = {
...createScan(1),
attributes: {
...createScan(1).attributes,
state: "scheduled",
graph_data_ready: true,
},
};
render(<ScanListTable scans={[scheduledScan]} />);
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",
);
});
// PROWLER-1383: Tooltip on disabled button explaining why it can't be selected
it("shows tooltip on disabled button explaining graph data is not available", () => {
const unavailableScan: AttackPathScan = {
...createScan(1),
attributes: {
...createScan(1).attributes,
state: "executing",
graph_data_ready: false,
},
};
render(<ScanListTable scans={[noGraphScan]} />);
render(<ScanListTable scans={[unavailableScan]} />);
expect(
screen.queryByLabelText("Graph data available"),
).not.toBeInTheDocument();
expect(screen.getByRole("tooltip")).toHaveTextContent(
"Graph data not yet available",
);
});
it("does not show tooltip on enabled button", () => {
render(<ScanListTable scans={[createScan(1)]} />);
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
});
});

View File

@@ -13,10 +13,8 @@ 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 { SCAN_STATES } from "@/types/attack-paths";
import { ScanStatusBadge } from "./scan-status-badge";
@@ -26,6 +24,7 @@ interface ScanListTableProps {
const DEFAULT_PAGE_SIZE = 5;
const PAGE_SIZE_OPTIONS = [2, 5, 10, 15];
const parsePageParam = (value: string | null, fallback: number) => {
if (!value) return fallback;
@@ -57,47 +56,7 @@ 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 (scan.attributes.state === SCAN_STATES.FAILED) {
return "Failed";
}
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;
return "Unavailable";
};
const getSelectedRowSelection = (
@@ -140,26 +99,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,7 +161,6 @@ 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
@@ -232,7 +175,7 @@ const getColumns = ({
</Button>
);
if (isDisabled && tooltip) {
if (isDisabled && selectedScanId !== row.original.id) {
return (
<div className="flex justify-end">
<Tooltip>
@@ -241,7 +184,7 @@ const getColumns = ({
{button}
</span>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
<TooltipContent>Graph data not yet available</TooltipContent>
</Tooltip>
</div>
);

View File

@@ -29,12 +29,12 @@ const BADGE_CONFIG: Record<
[SCAN_STATES.EXECUTING]: {
className: "bg-bg-warning-secondary text-text-neutral-primary",
label: "In Progress",
showGraphDot: false,
showGraphDot: true,
},
[SCAN_STATES.COMPLETED]: {
className: "bg-bg-pass-secondary text-text-success-primary",
label: "Completed",
showGraphDot: false,
showGraphDot: true,
},
[SCAN_STATES.FAILED]: {
className: "bg-bg-fail-secondary text-text-error-primary",
@@ -57,7 +57,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
@@ -66,16 +66,16 @@ export const ScanStatusBadge = ({
? "Graph not available"
: "Graph not available yet";
const spinner = status === SCAN_STATES.EXECUTING && (
<Loader2 size={14} className="animate-spin" />
);
const icon =
status === SCAN_STATES.EXECUTING ? (
<Loader2
size={14}
className={
graphDataReady
? "text-text-success-primary animate-spin"
: "animate-spin"
}
/>
<>
{graphDot}
{spinner}
</>
) : (
graphDot
);

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

View File

@@ -8,8 +8,9 @@ import {
Server,
Shield,
} from "lucide-react";
import { useState } from "react";
import { useEffect, useState, useTransition } from "react";
import { getResourceEvents } from "@/actions/resources";
import {
Alert,
AlertDescription,
@@ -24,8 +25,6 @@ import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { cn } from "@/lib/utils";
import { ResourceEventProps } from "@/types";
import { useResourceEventsTimeline } from "./use-resource-events-timeline";
interface EventsTimelineProps {
resourceId?: string;
isAwsProvider: boolean;
@@ -35,17 +34,59 @@ export const EventsTimeline = ({
resourceId,
isAwsProvider,
}: EventsTimelineProps) => {
const [events, setEvents] = useState<ResourceEventProps[]>([]);
const [error, setError] = useState<string | null>(null);
const [errorStatus, setErrorStatus] = useState<number | null>(null);
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
const [includeReadEvents, setIncludeReadEvents] = useState(false);
const [hasFetched, setHasFetched] = useState(false);
const [retryCount, setRetryCount] = useState(0);
const { events, error, errorStatus, hasFetched, isPending } =
useResourceEventsTimeline({
resourceId,
isAwsProvider,
includeReadEvents,
retryCount,
const [isPending, startTransition] = useTransition();
useEffect(() => {
if (!isAwsProvider || !resourceId) return;
let cancelled = false;
setError(null);
setErrorStatus(null);
setHasFetched(false);
startTransition(async () => {
try {
const response = await getResourceEvents(resourceId, {
includeReadEvents,
});
if (cancelled) return;
if (!response) {
setError("Failed to fetch events. Please try again.");
return;
}
if (response.error) {
setError(response.error);
setErrorStatus(response.status || null);
return;
}
setEvents(response.data || []);
setExpandedRows(new Set());
} catch (err) {
if (cancelled) return;
console.error("Error fetching events:", err);
setError("An unexpected error occurred.");
} finally {
if (!cancelled) setHasFetched(true);
}
});
return () => {
cancelled = true;
};
}, [resourceId, includeReadEvents, isAwsProvider, retryCount]);
const toggleRow = (eventId: string) => {
setExpandedRows((prev) => {
const next = new Set(prev);

View File

@@ -1,85 +0,0 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { getResourceEvents } from "@/actions/resources";
import { ResourceEventProps } from "@/types";
interface UseResourceEventsTimelineOptions {
resourceId?: string;
isAwsProvider: boolean;
includeReadEvents: boolean;
retryCount: number;
}
interface UseResourceEventsTimelineReturn {
events: ResourceEventProps[];
error: string | null;
errorStatus: number | null;
hasFetched: boolean;
isPending: boolean;
}
export function useResourceEventsTimeline({
resourceId,
isAwsProvider,
includeReadEvents,
retryCount,
}: UseResourceEventsTimelineOptions): UseResourceEventsTimelineReturn {
const [events, setEvents] = useState<ResourceEventProps[]>([]);
const [error, setError] = useState<string | null>(null);
const [errorStatus, setErrorStatus] = useState<number | null>(null);
const [hasFetched, setHasFetched] = useState(false);
const [isPending, startTransition] = useTransition();
useEffect(() => {
if (!isAwsProvider || !resourceId) return;
let cancelled = false;
setError(null);
setErrorStatus(null);
setHasFetched(false);
startTransition(async () => {
try {
const response = await getResourceEvents(resourceId, {
includeReadEvents,
});
if (cancelled) return;
if (!response) {
setError("Failed to fetch events. Please try again.");
return;
}
if (response.error) {
setError(response.error);
setErrorStatus(response.status || null);
return;
}
setEvents(response.data || []);
} catch (err) {
if (cancelled) return;
console.error("Error fetching events:", err);
setError("An unexpected error occurred.");
} finally {
if (!cancelled) setHasFetched(true);
}
});
return () => {
cancelled = true;
};
}, [resourceId, includeReadEvents, isAwsProvider, retryCount]);
return {
events,
error,
errorStatus,
hasFetched,
isPending,
};
}

View File

@@ -3,7 +3,6 @@
import {
HighlightStyle,
StreamLanguage,
type StringStream,
syntaxHighlighting,
} from "@codemirror/language";
import { EditorState } from "@codemirror/state";
@@ -21,16 +20,12 @@ import { type HTMLAttributes, useState } from "react";
import { Badge } from "@/components/shadcn";
import { cn } from "@/lib/utils";
export const QUERY_EDITOR_LANGUAGE = {
const QUERY_EDITOR_LANGUAGE = {
OPEN_CYPHER: "openCypher",
PLAIN_TEXT: "plainText",
SHELL: "shell",
HCL: "hcl",
BICEP: "bicep",
YAML: "yaml",
} as const;
export type QueryEditorLanguage =
type QueryEditorLanguage =
(typeof QUERY_EDITOR_LANGUAGE)[keyof typeof QUERY_EDITOR_LANGUAGE];
const OPEN_CYPHER_KEYWORDS = new Set([
@@ -103,204 +98,11 @@ const OPEN_CYPHER_FUNCTIONS = new Set([
"type",
]);
const SHELL_KEYWORDS = new Set([
"if",
"then",
"else",
"elif",
"fi",
"for",
"in",
"do",
"done",
"while",
"until",
"case",
"esac",
"function",
"return",
"export",
"local",
"readonly",
"declare",
"typeset",
"unset",
"shift",
"break",
"continue",
"select",
"time",
"trap",
]);
const SHELL_COMMANDS = new Set([
"aws",
"az",
"gcloud",
"kubectl",
"terraform",
"echo",
"grep",
"sed",
"awk",
"curl",
"wget",
"chmod",
"chown",
"mkdir",
"rm",
"cp",
"mv",
"cat",
"ls",
]);
const HCL_KEYWORDS = new Set([
"resource",
"data",
"variable",
"output",
"locals",
"module",
"provider",
"terraform",
"backend",
"required_providers",
"dynamic",
"for_each",
"count",
"depends_on",
"lifecycle",
"provisioner",
"connection",
]);
const HCL_FUNCTIONS = new Set([
"lookup",
"merge",
"join",
"split",
"length",
"element",
"concat",
"format",
"replace",
"regex",
"tolist",
"tomap",
"toset",
"try",
"can",
"file",
"templatefile",
"jsonencode",
"jsondecode",
"yamlencode",
"yamldecode",
"base64encode",
"base64decode",
"md5",
"sha256",
"cidrsubnet",
"cidrhost",
]);
const BICEP_KEYWORDS = new Set([
"resource",
"module",
"param",
"var",
"output",
"type",
"metadata",
"import",
"using",
"extension",
"targetScope",
"existing",
"if",
"for",
"in",
"true",
"false",
"null",
]);
const BICEP_DECORATORS = new Set([
"description",
"secure",
"minLength",
"maxLength",
"minValue",
"maxValue",
"allowed",
"metadata",
"batchSize",
"sys",
]);
const BICEP_FUNCTIONS = new Set([
"concat",
"format",
"toLower",
"toUpper",
"substring",
"replace",
"split",
"join",
"length",
"contains",
"empty",
"first",
"last",
"indexOf",
"array",
"union",
"intersection",
"resourceId",
"subscriptionResourceId",
"tenantResourceId",
"reference",
"listKeys",
"listAccountSas",
"uniqueString",
"guid",
"base64",
"uri",
"environment",
"subscription",
"resourceGroup",
"tenant",
]);
interface OpenCypherParserState {
inBlockComment: boolean;
inString: "'" | '"' | null;
}
interface ShellParserState {
inString: "'" | '"' | null;
}
interface HclParserState {
inBlockComment: boolean;
inString: '"' | null;
expectBlockType: boolean;
heredocTerminator: string | null;
}
interface BicepParserState {
inBlockComment: boolean;
inString: "'" | null;
expectResourceType: boolean;
}
interface YamlParserState {
inString: "'" | '"' | null;
inBlockScalar: boolean;
blockScalarIndent: number;
}
const openCypherLanguage = StreamLanguage.define<OpenCypherParserState>({
startState() {
return {
@@ -414,595 +216,6 @@ const openCypherLanguage = StreamLanguage.define<OpenCypherParserState>({
},
});
const shellLanguage = StreamLanguage.define<ShellParserState>({
startState() {
return {
inString: null,
};
},
token(stream, state) {
if (state.inString) {
let escaped = false;
while (!stream.eol()) {
const next = stream.next();
if (escaped) {
escaped = false;
continue;
}
if (next === "\\" && state.inString === '"') {
escaped = true;
continue;
}
if (next === state.inString) {
state.inString = null;
break;
}
}
if (stream.eol()) {
state.inString = null;
}
return "string";
}
if (stream.eatSpace()) {
return null;
}
if (stream.peek() === "#") {
stream.skipToEnd();
return "comment";
}
if (stream.match(/\$\([^)]+\)/)) {
return "variableName";
}
if (stream.match(/\$\{[A-Za-z_][\w]*\}/)) {
return "variableName";
}
if (stream.match(/\$[A-Za-z_][\w]*/)) {
return "variableName";
}
const quote = stream.peek();
if (quote === "'" || quote === '"') {
state.inString = quote;
stream.next();
return "string";
}
if (stream.match(/--[A-Za-z0-9][\w-]*/)) {
return "operator";
}
if (stream.match(/-[A-Za-z0-9]+/)) {
return "operator";
}
if (stream.match(/\|\||&&|>>|[|><;]/)) {
return "operator";
}
if (stream.match(/\d+(?:\.\d+)?/)) {
return "number";
}
if (stream.match(/[A-Za-z_][\w-]*/)) {
const currentValue = stream.current();
const normalizedValue = currentValue.toLowerCase();
if (SHELL_KEYWORDS.has(normalizedValue)) {
return "keyword";
}
if (SHELL_COMMANDS.has(normalizedValue)) {
return "function";
}
return "variableName";
}
stream.next();
return null;
},
});
const hclLanguage = StreamLanguage.define<HclParserState>({
startState() {
return {
inBlockComment: false,
inString: null,
expectBlockType: false,
heredocTerminator: null,
};
},
token(stream, state) {
if (state.heredocTerminator) {
// Match the closing terminator on its own line, including indented <<-EOF forms.
if (
stream.sol() &&
stream.match(new RegExp(`^\\s*${state.heredocTerminator}\\s*$`))
) {
state.heredocTerminator = null;
return "keyword";
}
stream.skipToEnd();
return "string";
}
if (state.inBlockComment) {
while (!stream.eol()) {
if (stream.match("*/")) {
state.inBlockComment = false;
break;
}
stream.next();
}
return "comment";
}
if (state.inString) {
let escaped = false;
while (!stream.eol()) {
const next = stream.next();
if (escaped) {
escaped = false;
continue;
}
if (next === "\\") {
escaped = true;
continue;
}
if (next === state.inString) {
state.inString = null;
break;
}
}
if (stream.eol()) {
state.inString = null;
}
return "string";
}
if (stream.eatSpace()) {
return null;
}
if (stream.match("#") || stream.match("//")) {
stream.skipToEnd();
return "comment";
}
if (stream.match("/*")) {
state.inBlockComment = true;
return "comment";
}
if (stream.match(/\$\{[^}]+\}/)) {
return "variableName";
}
if (stream.peek() === '"') {
if (state.expectBlockType) {
state.expectBlockType = false;
stream.next(); // opening "
while (!stream.eol()) {
const ch = stream.next();
if (ch === "\\") {
stream.next(); // skip escaped char
} else if (ch === '"') {
break;
}
}
return "typeName";
}
state.inString = '"';
stream.next();
return "string";
}
if (stream.match(/[{}\[\]()]/)) {
return "punctuation";
}
// Heredoc (<<EOF, <<-EOF) — must be before generic operator matcher
if (stream.match(/<<-?([A-Za-z_][\w]*)/)) {
state.heredocTerminator = stream.current().replace(/^<<-?/, "");
return "keyword";
}
if (stream.match(/=>|\.\.\.|==|!=|>=|<=|[=><?:]/)) {
return "operator";
}
if (stream.match(/\b(?:true|false)\b/)) {
return "keyword";
}
if (stream.match(/\d+(?:\.\d+)?/)) {
return "number";
}
if (stream.match(/[A-Za-z_][\w-]*/)) {
const currentValue = stream.current();
const normalizedValue = currentValue.toLowerCase();
if (HCL_KEYWORDS.has(normalizedValue)) {
state.expectBlockType =
normalizedValue === "resource" || normalizedValue === "data";
return "keyword";
}
if (
HCL_FUNCTIONS.has(normalizedValue) &&
stream.match(/\s*(?=\()/, false)
) {
return "function";
}
if (state.expectBlockType) {
state.expectBlockType = false;
return "typeName";
}
if (stream.match(/\s*(?==)/, false)) {
return "propertyName";
}
return "variableName";
}
stream.next();
return null;
},
});
const yamlLanguage = StreamLanguage.define<YamlParserState>({
startState() {
return {
inString: null,
inBlockScalar: false,
blockScalarIndent: 0,
};
},
token(stream, state) {
// Block scalar continuation (| or > multiline strings)
if (state.inBlockScalar) {
// Blank lines are always part of a block scalar.
if (stream.match(/^\s*$/)) {
stream.skipToEnd();
return "string";
}
const indent = stream.indentation();
if (indent > state.blockScalarIndent) {
stream.skipToEnd();
return "string";
}
state.inBlockScalar = false;
state.blockScalarIndent = 0;
}
// Continue quoted strings across tokens
if (state.inString) {
let escaped = false;
while (!stream.eol()) {
const next = stream.next();
if (escaped) {
escaped = false;
continue;
}
if (next === "\\" && state.inString === '"') {
escaped = true;
continue;
}
if (next === state.inString) {
state.inString = null;
break;
}
}
return "string";
}
if (stream.eatSpace()) {
return null;
}
// Comments
if (stream.peek() === "#") {
stream.skipToEnd();
return "comment";
}
// Document markers
if (stream.sol() && (stream.match("---") || stream.match("..."))) {
return "keyword";
}
// Anchors & aliases
if (stream.match(/[&*][A-Za-z_][\w]*/)) {
return "variableName";
}
// CloudFormation intrinsic tags (!Ref, !Sub, !GetAtt, etc.)
if (stream.match(/![A-Za-z][A-Za-z0-9]*/)) {
return "typeName";
}
// Tags (!!str, !!map, etc.)
if (stream.match(/!![A-Za-z]+/)) {
return "typeName";
}
// Quoted strings
const quote = stream.peek();
if (quote === "'" || quote === '"') {
state.inString = quote;
stream.next();
return "string";
}
// Block scalar indicators (| or >)
if (
stream.sol() === false &&
(stream.peek() === "|" || stream.peek() === ">")
) {
const prevChar = stream.string.charAt(stream.pos - 1);
if (prevChar === " " || prevChar === ":") {
stream.next();
// Eat optional modifiers like |-, |+, |2
stream.match(/[-+]?\d?/);
state.inBlockScalar = true;
state.blockScalarIndent = stream.indentation();
return "operator";
}
}
// List item marker
if (stream.match(/^-(?=\s)/)) {
return "punctuation";
}
// Key: value pattern — supports CloudFormation long-form intrinsics (Fn::Sub:)
if (stream.match(/[A-Za-z_][\w./-]*(?:::[A-Za-z_][\w]*)*(?=\s*:(?!:))/)) {
return "propertyName";
}
// Booleans & null (YAML spec values)
if (stream.match(/\b(?:true|false|yes|no|on|off|null)\b/i)) {
return "keyword";
}
// Numbers (integers, floats, hex, octal)
if (
stream.match(
/^[-+]?(?:0x[0-9a-fA-F]+|0o[0-7]+|0b[01]+|\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)/,
)
) {
return "number";
}
// Colon separator
if (stream.match(":")) {
return "punctuation";
}
// Braces/brackets (flow style)
if (stream.match(/[{}\[\],]/)) {
return "punctuation";
}
// Tilde (null alias)
if (stream.match("~")) {
return "keyword";
}
// Unquoted strings / values — consume word
if (stream.match(/[^\s#:,\[\]{}]+/)) {
return "string";
}
stream.next();
return null;
},
});
function readBicepStringSegment(
stream: StringStream,
includeOpeningQuote = false,
) {
if (includeOpeningQuote) {
stream.next();
}
while (!stream.eol()) {
if (stream.match("${")) {
stream.backUp(2);
break;
}
const next = stream.next();
if (next === "'" && stream.peek() === "'") {
stream.next();
continue;
}
if (next === "'") {
stream.backUp(1);
break;
}
}
}
const bicepLanguage = StreamLanguage.define<BicepParserState>({
startState() {
return {
inBlockComment: false,
inString: null,
expectResourceType: false,
};
},
token(stream, state) {
if (state.inBlockComment) {
while (!stream.eol()) {
if (stream.match("*/")) {
state.inBlockComment = false;
break;
}
stream.next();
}
return "comment";
}
if (state.inString) {
if (stream.match("${")) {
let depth = 1;
while (!stream.eol() && depth > 0) {
const next = stream.next();
if (next === "{") {
depth += 1;
continue;
}
if (next === "}") {
depth -= 1;
}
}
return "variableName";
}
readBicepStringSegment(stream);
if (stream.peek() !== "'") {
return "string";
}
if (stream.peek() === "'") {
state.inString = null;
stream.next();
}
return "string";
}
if (stream.eatSpace()) {
return null;
}
if (stream.match("//")) {
stream.skipToEnd();
return "comment";
}
if (stream.match("/*")) {
state.inBlockComment = true;
return "comment";
}
if (stream.match(/@[A-Za-z_][\w]*/)) {
const decorator = stream.current().slice(1);
if (BICEP_DECORATORS.has(decorator)) {
return "keyword";
}
return "keyword";
}
if (stream.peek() === "'") {
if (state.expectResourceType) {
state.expectResourceType = false;
// Consume the full quoted resource type including both quotes
stream.next(); // opening '
while (!stream.eol()) {
const ch = stream.next();
if (ch === "'" && stream.peek() === "'") {
stream.next(); // escaped ''
continue;
}
if (ch === "'") {
break; // closing '
}
}
return "typeName";
}
stream.next(); // consume opening '
state.inString = "'";
return "string";
}
if (stream.match(/[A-Za-z_][\w-]*(?=\s*:)/)) {
return "propertyName";
}
if (stream.match(/\?\?|==|!=|>=|<=|=|>|<|\?|:|!/)) {
return "operator";
}
if (stream.match(/[{}\[\]()]/)) {
return "punctuation";
}
if (stream.match(/-?\d+(?:\.\d+)?/)) {
return "number";
}
if (stream.match(/[A-Za-z_][\w]*/)) {
const currentValue = stream.current();
if (BICEP_KEYWORDS.has(currentValue)) {
state.expectResourceType =
currentValue === "resource" || currentValue === "module";
return "keyword";
}
if (
BICEP_FUNCTIONS.has(currentValue) &&
stream.match(/\s*(?=\()/, false)
) {
return "function";
}
return "variableName";
}
stream.next();
return null;
},
});
const lightHighlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: "#0550ae", fontWeight: "600" },
{ tag: tags.string, color: "#0a3069" },
@@ -1167,14 +380,6 @@ export const QueryCodeEditor = ({
openCypherLanguage,
syntaxHighlighting(editorHighlightStyle),
);
} else if (language === QUERY_EDITOR_LANGUAGE.SHELL) {
extensions.push(shellLanguage, syntaxHighlighting(editorHighlightStyle));
} else if (language === QUERY_EDITOR_LANGUAGE.HCL) {
extensions.push(hclLanguage, syntaxHighlighting(editorHighlightStyle));
} else if (language === QUERY_EDITOR_LANGUAGE.BICEP) {
extensions.push(bicepLanguage, syntaxHighlighting(editorHighlightStyle));
} else if (language === QUERY_EDITOR_LANGUAGE.YAML) {
extensions.push(yamlLanguage, syntaxHighlighting(editorHighlightStyle));
}
const handleCopy = async () => {

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("custom link", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const filePath = path.join(currentDir, "custom-link.tsx");
const source = readFileSync(filePath, "utf8");
it("renders external or placeholder hrefs as plain anchors instead of next/link", () => {
expect(source).toContain("isExternalHref");
expect(source).toContain("hasDynamicHrefPlaceholder");
expect(source).toContain("<a");
});
});

View File

@@ -1,27 +1,20 @@
import Link from "next/link";
import { type AnchorHTMLAttributes, forwardRef, type ReactNode } from "react";
import React from "react";
import { cn } from "@/lib";
interface CustomLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
interface CustomLinkProps
extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
href: string;
target?: "_self" | "_blank" | string;
ariaLabel?: string;
className?: string;
children: ReactNode;
children: React.ReactNode;
scroll?: boolean;
size?: string;
}
function isExternalHref(href: string) {
return /^https?:\/\//.test(href) || href.startsWith("mailto:");
}
function hasDynamicHrefPlaceholder(href: string) {
return href.includes("[") && href.includes("]");
}
export const CustomLink = forwardRef<HTMLAnchorElement, CustomLinkProps>(
export const CustomLink = React.forwardRef<HTMLAnchorElement, CustomLinkProps>(
(
{
href,
@@ -35,29 +28,6 @@ export const CustomLink = forwardRef<HTMLAnchorElement, CustomLinkProps>(
},
ref,
) => {
const linkClassName = cn(
`text-${size} text-button-tertiary p-0`,
className,
);
const shouldUseAnchor =
isExternalHref(href) || hasDynamicHrefPlaceholder(href);
if (shouldUseAnchor) {
return (
<a
ref={ref}
href={href}
aria-label={ariaLabel}
target={target}
rel={target === "_blank" ? "noopener noreferrer" : undefined}
className={linkClassName}
{...props}
>
{children}
</a>
);
}
return (
<Link
ref={ref}
@@ -66,7 +36,7 @@ export const CustomLink = forwardRef<HTMLAnchorElement, CustomLinkProps>(
aria-label={ariaLabel}
target={target}
rel={target === "_blank" ? "noopener noreferrer" : undefined}
className={linkClassName}
className={cn(`text-${size} text-button-tertiary p-0`, className)}
{...props}
>
{children}

View File

@@ -1,159 +0,0 @@
"use client";
import { OnChangeFn, Row, RowSelectionState } from "@tanstack/react-table";
import { useState } from "react";
import { canMuteFindingResource } from "@/components/findings/table/finding-resource-selection";
import { useResourceDetailDrawer } from "@/components/findings/table/resource-detail-drawer";
import { useFindingGroupResources } from "@/hooks/use-finding-group-resources";
import { FindingGroupRow, FindingResourceRow } from "@/types";
interface UseFindingGroupResourceStateOptions {
group: FindingGroupRow;
filters: Record<string, string>;
hasHistoricalData: boolean;
onResourceSelectionChange?: (findingIds: string[]) => void;
scrollContainerRef?: React.RefObject<HTMLElement | null>;
}
interface UseFindingGroupResourceStateReturn {
rowSelection: RowSelectionState;
resources: FindingResourceRow[];
isLoading: boolean;
sentinelRef: (node: HTMLDivElement | null) => void;
refresh: () => void;
loadMore: () => void;
totalCount: number | null;
drawer: ReturnType<typeof useResourceDetailDrawer>;
handleDrawerMuteComplete: () => void;
selectedFindingIds: string[];
selectableRowCount: number;
getRowCanSelect: (row: Row<FindingResourceRow>) => boolean;
clearSelection: () => void;
isSelected: (id: string) => boolean;
handleMuteComplete: () => void;
handleRowSelectionChange: OnChangeFn<RowSelectionState>;
resolveSelectedFindingIds: (ids: string[]) => Promise<string[]>;
}
export function useFindingGroupResourceState({
group,
filters,
hasHistoricalData,
onResourceSelectionChange,
scrollContainerRef,
}: UseFindingGroupResourceStateOptions): UseFindingGroupResourceStateReturn {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [resources, setResources] = useState<FindingResourceRow[]>([]);
const [isLoading, setIsLoading] = useState(true);
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 } =
useFindingGroupResources({
checkId: group.checkId,
hasDateOrScanFilter: hasHistoricalData,
filters,
onSetResources: handleSetResources,
onAppendResources: handleAppendResources,
onSetLoading: handleSetLoading,
scrollContainerRef,
});
const drawer = useResourceDetailDrawer({
resources,
checkId: group.checkId,
totalResourceCount: totalCount ?? group.resourcesTotal,
onRequestMoreResources: loadMore,
});
const handleDrawerMuteComplete = () => {
drawer.refetchCurrent();
refresh();
};
const selectedFindingIds = Object.keys(rowSelection)
.filter((key) => rowSelection[key])
.map((idx) => resources[parseInt(idx)]?.findingId)
.filter((id): id is string => Boolean(id));
const selectableRowCount = resources.filter(canMuteFindingResource).length;
const getRowCanSelect = (row: Row<FindingResourceRow>): boolean => {
return canMuteFindingResource(row.original);
};
const clearSelection = () => {
setRowSelection({});
onResourceSelectionChange?.([]);
};
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);
if (onResourceSelectionChange) {
const newFindingIds = Object.keys(newSelection)
.filter((key) => newSelection[key])
.map((idx) => resources[parseInt(idx)]?.findingId)
.filter((id): id is string => Boolean(id));
onResourceSelectionChange(newFindingIds);
}
};
const resolveSelectedFindingIds = async (ids: string[]) => {
return ids.filter(Boolean);
};
return {
rowSelection,
resources,
isLoading,
sentinelRef,
refresh,
loadMore,
totalCount,
drawer,
handleDrawerMuteComplete,
selectedFindingIds,
selectableRowCount,
getRowCanSelect,
clearSelection,
isSelected,
handleMuteComplete,
handleRowSelectionChange,
resolveSelectedFindingIds,
};
}

View File

@@ -1,206 +0,0 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useFindingGroupResources } from "./use-finding-group-resources";
type IntersectionCallback = (entries: IntersectionObserverEntry[]) => void;
let latestObserverCallback: IntersectionCallback | null = null;
class MockIntersectionObserver {
callback: IntersectionCallback;
constructor(callback: IntersectionCallback) {
this.callback = callback;
latestObserverCallback = callback;
}
observe() {}
unobserve() {}
disconnect() {
if (latestObserverCallback === this.callback) {
latestObserverCallback = null;
}
}
}
function triggerIntersection() {
latestObserverCallback?.([
{ isIntersecting: true } as IntersectionObserverEntry,
]);
}
const findingGroupActionsMock = vi.hoisted(() => ({
getLatestFindingGroupResources: vi.fn(),
getFindingGroupResources: vi.fn(),
adaptFindingGroupResourcesResponse: vi.fn(),
}));
vi.mock("@/actions/finding-groups", () => findingGroupActionsMock);
function makeApiResponse(
resources: { id: string }[],
{ pages = 1 }: { pages?: number } = {},
) {
return {
data: resources,
meta: { pagination: { pages } },
};
}
function fakeResource(id: string) {
return {
findingId: id,
resourceUid: `uid-${id}`,
resourceName: `Resource ${id}`,
status: "FAIL",
severity: "high",
isMuted: false,
};
}
function defaultOptions(overrides?: Record<string, unknown>) {
return {
checkId: "check_1",
hasDateOrScanFilter: false,
filters: {},
onSetResources: vi.fn(),
onAppendResources: vi.fn(),
onSetLoading: vi.fn(),
...overrides,
};
}
async function flushAsync() {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
}
describe("useFindingGroupResources", () => {
beforeEach(() => {
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
for (const mockFn of Object.values(findingGroupActionsMock)) {
mockFn.mockReset();
}
});
describe("when mounting", () => {
it("should fetch page 1 and deliver resources via onSetResources", async () => {
const apiResponse = makeApiResponse([{ id: "r1" }, { id: "r2" }], {
pages: 1,
});
const adapted = [fakeResource("r1"), fakeResource("r2")];
findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue(
apiResponse,
);
findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
adapted,
);
const onSetResources = vi.fn();
const onSetLoading = vi.fn();
renderHook(() =>
useFindingGroupResources(
defaultOptions({ onSetResources, onSetLoading }),
),
);
await flushAsync();
expect(
findingGroupActionsMock.getLatestFindingGroupResources,
).toHaveBeenCalledWith(
expect.objectContaining({
checkId: "check_1",
page: 1,
pageSize: 10,
}),
);
expect(onSetResources).toHaveBeenCalledWith(adapted, false);
});
it("should use getFindingGroupResources when hasDateOrScanFilter is true", async () => {
const apiResponse = makeApiResponse([], { pages: 1 });
findingGroupActionsMock.getFindingGroupResources.mockResolvedValue(
apiResponse,
);
findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
[],
);
renderHook(() =>
useFindingGroupResources(defaultOptions({ hasDateOrScanFilter: true })),
);
await flushAsync();
expect(
findingGroupActionsMock.getFindingGroupResources,
).toHaveBeenCalledTimes(1);
expect(
findingGroupActionsMock.getLatestFindingGroupResources,
).not.toHaveBeenCalled();
});
it("should forward the active finding-group filters to the resources endpoint", async () => {
const apiResponse = makeApiResponse([], { pages: 1 });
const filters = {
"filter[status__in]": "PASS",
"filter[severity__in]": "medium",
"filter[provider_type__in]": "aws",
};
findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue(
apiResponse,
);
findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
[],
);
renderHook(() => useFindingGroupResources(defaultOptions({ filters })));
await flushAsync();
expect(
findingGroupActionsMock.getLatestFindingGroupResources,
).toHaveBeenCalledWith(
expect.objectContaining({
checkId: "check_1",
page: 1,
pageSize: 10,
filters,
}),
);
});
});
describe("when all resources fit in one page", () => {
it("should not fetch page 2 after page 1 completes", async () => {
const apiResponse = makeApiResponse(
[{ id: "r1" }, { id: "r2" }, { id: "r3" }, { id: "r4" }],
{ pages: 1 },
);
findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue(
apiResponse,
);
findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
[
fakeResource("r1"),
fakeResource("r2"),
fakeResource("r3"),
fakeResource("r4"),
],
);
const { result } = renderHook(() =>
useFindingGroupResources(defaultOptions()),
);
await flushAsync();
act(() => triggerIntersection());
await flushAsync();
expect(
findingGroupActionsMock.getLatestFindingGroupResources,
).toHaveBeenCalledTimes(1);
expect(result.current.totalCount).toBeNull();
});
});
});

View File

@@ -0,0 +1,570 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useInfiniteResources } from "./use-infinite-resources";
// ---------------------------------------------------------------------------
// IntersectionObserver mock (jsdom doesn't provide one)
// ---------------------------------------------------------------------------
type IntersectionCallback = (entries: IntersectionObserverEntry[]) => void;
/** Stores the latest observer callback so tests can trigger intersections. */
let latestObserverCallback: IntersectionCallback | null = null;
class MockIntersectionObserver {
callback: IntersectionCallback;
constructor(callback: IntersectionCallback) {
this.callback = callback;
latestObserverCallback = callback;
}
observe() {}
unobserve() {}
disconnect() {
if (latestObserverCallback === this.callback) {
latestObserverCallback = null;
}
}
}
/** Simulate the sentinel becoming visible in the scroll container. */
function triggerIntersection() {
latestObserverCallback?.([
{ isIntersecting: true } as IntersectionObserverEntry,
]);
}
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const findingGroupActionsMock = vi.hoisted(() => ({
getLatestFindingGroupResources: vi.fn(),
getFindingGroupResources: vi.fn(),
adaptFindingGroupResourcesResponse: vi.fn(),
}));
vi.mock("@/actions/finding-groups", () => findingGroupActionsMock);
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeApiResponse(
resources: { id: string }[],
{ pages = 1 }: { pages?: number } = {},
) {
return {
data: resources,
meta: { pagination: { pages } },
};
}
function fakeResource(id: string) {
return {
findingId: id,
resourceUid: `uid-${id}`,
resourceName: `Resource ${id}`,
status: "FAIL",
severity: "high",
isMuted: false,
};
}
function defaultOptions(overrides?: Record<string, unknown>) {
return {
checkId: "check_1",
hasDateOrScanFilter: false,
filters: {},
onSetResources: vi.fn(),
onAppendResources: vi.fn(),
onSetLoading: vi.fn(),
...overrides,
};
}
/** Flush all pending microtasks (awaits in fetchPage). */
async function flushAsync() {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("useInfiniteResources", () => {
beforeEach(() => {
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
for (const mockFn of Object.values(findingGroupActionsMock)) {
mockFn.mockReset();
}
});
describe("when mounting", () => {
it("should fetch page 1 and deliver resources via onSetResources", async () => {
// Given
const apiResponse = makeApiResponse([{ id: "r1" }, { id: "r2" }], {
pages: 1,
});
const adapted = [fakeResource("r1"), fakeResource("r2")];
findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue(
apiResponse,
);
findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
adapted,
);
const onSetResources = vi.fn();
const onSetLoading = vi.fn();
// When
renderHook(() =>
useInfiniteResources(defaultOptions({ onSetResources, onSetLoading })),
);
await flushAsync();
// Then
expect(
findingGroupActionsMock.getLatestFindingGroupResources,
).toHaveBeenCalledWith(
expect.objectContaining({
checkId: "check_1",
page: 1,
pageSize: 10,
}),
);
expect(onSetResources).toHaveBeenCalledWith(adapted, false);
});
it("should use getFindingGroupResources when hasDateOrScanFilter is true", async () => {
// Given
const apiResponse = makeApiResponse([], { pages: 1 });
findingGroupActionsMock.getFindingGroupResources.mockResolvedValue(
apiResponse,
);
findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
[],
);
// When
renderHook(() =>
useInfiniteResources(defaultOptions({ hasDateOrScanFilter: true })),
);
await flushAsync();
// Then
expect(
findingGroupActionsMock.getFindingGroupResources,
).toHaveBeenCalledTimes(1);
expect(
findingGroupActionsMock.getLatestFindingGroupResources,
).not.toHaveBeenCalled();
});
it("should forward the active finding-group filters to the resources endpoint", async () => {
// Given
const apiResponse = makeApiResponse([], { pages: 1 });
const filters = {
"filter[status__in]": "PASS",
"filter[severity__in]": "medium",
"filter[provider_type__in]": "aws",
};
findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue(
apiResponse,
);
findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
[],
);
// When
renderHook(() => useInfiniteResources(defaultOptions({ filters })));
await flushAsync();
// Then
expect(
findingGroupActionsMock.getLatestFindingGroupResources,
).toHaveBeenCalledWith(
expect.objectContaining({
checkId: "check_1",
page: 1,
pageSize: 10,
filters,
}),
);
});
});
describe("when all resources fit in one page", () => {
it("should not fetch page 2 after page 1 completes", async () => {
// Given — API returns 4 resources, 1 page total
const apiResponse = makeApiResponse(
[{ id: "r1" }, { id: "r2" }, { id: "r3" }, { id: "r4" }],
{ pages: 1 },
);
findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue(
apiResponse,
);
findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
[
fakeResource("r1"),
fakeResource("r2"),
fakeResource("r3"),
fakeResource("r4"),
],
);
// When
const { result } = renderHook(() =>
useInfiniteResources(defaultOptions()),
);
await flushAsync();
// Attach sentinel so observer is created
const sentinel = document.createElement("div");
act(() => {
result.current.sentinelRef(sentinel);
});
// Simulate observer firing (sentinel visible after page 1 loaded)
act(() => {
triggerIntersection();
});
await flushAsync();
// Then — only page 1 was fetched, never page 2
const calls =
findingGroupActionsMock.getLatestFindingGroupResources.mock.calls;
const pageNumbers = calls.map(
(c: unknown[]) => (c[0] as { page: number }).page,
);
expect(pageNumbers.every((p: number) => p === 1)).toBe(true);
});
});
describe("when aborted fetch races with active fetch", () => {
it("should not reset isLoading when an aborted fetch resolves", async () => {
// Given — simulate the Strict Mode race condition:
// fetch1 starts, gets aborted, fetch2 starts, fetch1's finally runs
const onSetResources = vi.fn();
const onSetLoading = vi.fn();
// fetch1 resolves slowly (after abort)
let resolveFetch1: (v: unknown) => void;
const fetch1Promise = new Promise((r) => {
resolveFetch1 = r;
});
// fetch2 resolves normally
const apiResponse = makeApiResponse([{ id: "r1" }], { pages: 1 });
const adapted = [fakeResource("r1")];
let callCount = 0;
findingGroupActionsMock.getLatestFindingGroupResources.mockImplementation(
() => {
callCount++;
if (callCount === 1) return fetch1Promise;
return Promise.resolve(apiResponse);
},
);
findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
adapted,
);
// When — mount, abort (simulating cleanup), remount
const { unmount } = renderHook(() =>
useInfiniteResources(defaultOptions({ onSetResources, onSetLoading })),
);
// Simulate Strict Mode: unmount triggers abort
unmount();
// Fetch1 resolves AFTER abort — its finally should NOT reset isLoading
await act(async () => {
resolveFetch1!(apiResponse);
await new Promise((r) => setTimeout(r, 0));
});
// Then — onSetResources should NOT have been called by the aborted fetch
// (the signal.aborted check returns early)
expect(onSetResources).not.toHaveBeenCalled();
});
});
describe("when sentinel triggers next page", () => {
it("should fetch page 2 via onAppendResources when hasMore is true", async () => {
// Given — page 1 has more pages
const page1Response = makeApiResponse(
Array.from({ length: 10 }, (_, i) => ({ id: `r${i}` })),
{ pages: 3 },
);
const page1Adapted = Array.from({ length: 10 }, (_, i) =>
fakeResource(`r${i}`),
);
const page2Response = makeApiResponse(
Array.from({ length: 10 }, (_, i) => ({ id: `r${10 + i}` })),
{ pages: 3 },
);
const page2Adapted = Array.from({ length: 10 }, (_, i) =>
fakeResource(`r${10 + i}`),
);
findingGroupActionsMock.getLatestFindingGroupResources
.mockResolvedValueOnce(page1Response)
.mockResolvedValueOnce(page2Response);
findingGroupActionsMock.adaptFindingGroupResourcesResponse
.mockReturnValueOnce(page1Adapted)
.mockReturnValueOnce(page2Adapted);
const onSetResources = vi.fn();
const onAppendResources = vi.fn();
// When — mount and wait for page 1
const { result } = renderHook(() =>
useInfiniteResources(
defaultOptions({ onSetResources, onAppendResources }),
),
);
await flushAsync();
expect(onSetResources).toHaveBeenCalledWith(page1Adapted, true);
// Attach sentinel and simulate intersection → triggers page 2
const sentinel = document.createElement("div");
act(() => {
result.current.sentinelRef(sentinel);
});
act(() => {
triggerIntersection();
});
await flushAsync();
// Then
expect(onAppendResources).toHaveBeenCalledWith(page2Adapted, true);
expect(
findingGroupActionsMock.getLatestFindingGroupResources,
).toHaveBeenCalledTimes(2);
});
});
describe("when refresh is called", () => {
it("should re-fetch page 1 and deliver via onSetResources", async () => {
// Given
const apiResponse = makeApiResponse([{ id: "r1" }], { pages: 1 });
const adapted = [fakeResource("r1")];
findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue(
apiResponse,
);
findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
adapted,
);
const onSetResources = vi.fn();
const { result } = renderHook(() =>
useInfiniteResources(defaultOptions({ onSetResources })),
);
await flushAsync();
expect(onSetResources).toHaveBeenCalledTimes(1);
// When — refresh (e.g. after muting)
act(() => {
result.current.refresh();
});
await flushAsync();
// Then — page 1 fetched again
expect(onSetResources).toHaveBeenCalledTimes(2);
const calls =
findingGroupActionsMock.getLatestFindingGroupResources.mock.calls;
expect(calls).toHaveLength(2);
expect(calls[0][0].page).toBe(1);
expect(calls[1][0].page).toBe(1);
});
});
describe("when filters include search params", () => {
it("should pass filters to the fetch function", async () => {
// Given
const apiResponse = makeApiResponse([], { pages: 1 });
findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue(
apiResponse,
);
findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
[],
);
const filters = {
"filter[name__icontains]": "my-resource",
"filter[severity__in]": "high",
};
// When
renderHook(() => useInfiniteResources(defaultOptions({ filters })));
await flushAsync();
// Then
expect(
findingGroupActionsMock.getLatestFindingGroupResources,
).toHaveBeenCalledWith(expect.objectContaining({ filters }));
});
});
describe("when refresh() fires while loadNextPage is in-flight (race condition — Fix 5)", () => {
it("should discard in-flight page 2 and fetch page 1 when refresh fires during loadNextPage", async () => {
// Given — page 1 has 2 pages total, page 2 hangs indefinitely
const page1Response = makeApiResponse(
Array.from({ length: 10 }, (_, i) => ({ id: `r${i}` })),
{ pages: 2 },
);
const page1Adapted = Array.from({ length: 10 }, (_, i) =>
fakeResource(`r${i}`),
);
const page2Response = makeApiResponse(
Array.from({ length: 5 }, (_, i) => ({ id: `r${10 + i}` })),
{ pages: 2 },
);
const refreshPage1Response = makeApiResponse([{ id: "r-fresh-1" }], {
pages: 1,
});
const refreshPage1Adapted = [fakeResource("r-fresh-1")];
// page 2 hangs until we explicitly resolve it
let resolveNextPage: (v: unknown) => void = () => {};
const hangingPage2 = new Promise((r) => {
resolveNextPage = r;
});
let callCount = 0;
findingGroupActionsMock.getLatestFindingGroupResources.mockImplementation(
(args: { page: number }) => {
callCount++;
if (callCount === 1) {
return Promise.resolve(page1Response);
}
if (args.page === 2) {
return hangingPage2;
}
return Promise.resolve(refreshPage1Response);
},
);
findingGroupActionsMock.adaptFindingGroupResourcesResponse
.mockReturnValueOnce(page1Adapted)
.mockReturnValue(refreshPage1Adapted);
const onSetResources = vi.fn();
const onAppendResources = vi.fn();
// When — mount and wait for page 1
const { result } = renderHook(() =>
useInfiniteResources(
defaultOptions({ onSetResources, onAppendResources }),
),
);
await flushAsync();
expect(onSetResources).toHaveBeenCalledWith(page1Adapted, true);
// Trigger loadNextPage (increments pageRef to 2 in buggy code)
const sentinel = document.createElement("div");
act(() => {
result.current.sentinelRef(sentinel);
});
act(() => {
triggerIntersection();
});
// Do NOT flush — page 2 is hanging in-flight
// Refresh fires while page 2 is in-flight
act(() => {
result.current.refresh();
});
await flushAsync();
// Resolve hanging page 2 after refresh (simulates late stale response)
await act(async () => {
resolveNextPage(page2Response);
await new Promise((r) => setTimeout(r, 0));
});
// Then — the aborted page 2 must NOT deliver resources (signal.aborted check)
expect(onAppendResources).not.toHaveBeenCalled();
// The refresh must have fetched page 1 and delivered fresh resources
expect(onSetResources).toHaveBeenCalledWith(refreshPage1Adapted, false);
// The refresh call must request page=1 (not page=3 due to stale pageRef)
// Exact call sequence: [0]=initial page 1, [1]=loadNextPage page 2, [2]=refresh page 1
const calls =
findingGroupActionsMock.getLatestFindingGroupResources.mock.calls;
expect((calls[0][0] as { page: number }).page).toBe(1); // initial fetch
expect((calls[1][0] as { page: number }).page).toBe(2); // loadNextPage
expect((calls[2][0] as { page: number }).page).toBe(1); // refresh
});
it("should fetch sequential pages without skipping when loadNextPage is used normally", async () => {
// Given — page 1 has 3 pages; pages load sequentially
const makePageResponse = (startIdx: number, total: number) =>
makeApiResponse(
Array.from({ length: 5 }, (_, i) => ({ id: `r${startIdx + i}` })),
{ pages: total },
);
findingGroupActionsMock.getLatestFindingGroupResources
.mockResolvedValueOnce(makePageResponse(0, 3)) // page 1
.mockResolvedValueOnce(makePageResponse(5, 3)) // page 2
.mockResolvedValueOnce(makePageResponse(10, 3)); // page 3
findingGroupActionsMock.adaptFindingGroupResourcesResponse
.mockReturnValueOnce(
Array.from({ length: 5 }, (_, i) => fakeResource(`r${i}`)),
)
.mockReturnValueOnce(
Array.from({ length: 5 }, (_, i) => fakeResource(`r${5 + i}`)),
)
.mockReturnValueOnce(
Array.from({ length: 5 }, (_, i) => fakeResource(`r${10 + i}`)),
);
const onAppendResources = vi.fn();
// When — mount and wait for page 1
const { result } = renderHook(() =>
useInfiniteResources(defaultOptions({ onAppendResources })),
);
await flushAsync();
// Attach sentinel
const sentinel = document.createElement("div");
act(() => {
result.current.sentinelRef(sentinel);
});
// Load page 2
act(() => {
triggerIntersection();
});
await flushAsync();
// Load page 3
act(() => {
triggerIntersection();
});
await flushAsync();
// Then — pages were fetched in order: 2, 3 (not 2, 4 due to double-increment)
const calls =
findingGroupActionsMock.getLatestFindingGroupResources.mock.calls;
expect(calls[1][0].page).toBe(2);
expect(calls[2][0].page).toBe(3);
});
});
});

View File

@@ -12,7 +12,7 @@ import { FindingResourceRow } from "@/types";
const RESOURCES_PAGE_SIZE = 10;
interface UseFindingGroupResourcesOptions {
interface UseInfiniteResourcesOptions {
checkId: string;
hasDateOrScanFilter: boolean;
filters: Record<string, string | string[] | undefined>;
@@ -22,17 +22,28 @@ interface UseFindingGroupResourcesOptions {
hasMore: boolean,
) => void;
onSetLoading: (loading: boolean) => void;
/** Scroll container element for IntersectionObserver root. Defaults to viewport. */
scrollContainerRef?: React.RefObject<HTMLElement | null>;
}
interface UseFindingGroupResourcesReturn {
interface UseInfiniteResourcesReturn {
sentinelRef: (node: HTMLDivElement | null) => void;
/** Reset pagination and re-fetch page 1 (e.g. after muting). */
refresh: () => void;
/** Imperatively load the next page (e.g. from drawer navigation). */
loadMore: () => void;
/** Total number of resources matching current filters (from API pagination). */
totalCount: number | null;
}
export function useFindingGroupResources({
/**
* Hook for paginated infinite-scroll loading of finding group resources.
*
* Uses refs for all mutable state to avoid dependency chains that
* cause infinite re-render loops. The parent component remounts this
* hook via key-prop when checkId or filters change.
*/
export function useInfiniteResources({
checkId,
hasDateOrScanFilter,
filters,
@@ -40,21 +51,28 @@ export function useFindingGroupResources({
onAppendResources,
onSetLoading,
scrollContainerRef,
}: UseFindingGroupResourcesOptions): UseFindingGroupResourcesReturn {
}: UseInfiniteResourcesOptions): UseInfiniteResourcesReturn {
// All mutable state in refs to break dependency chains
const pageRef = useRef(1);
const hasMoreRef = useRef(true);
// Start as `true` to block the IntersectionObserver from calling loadNextPage
// before the initial fetch runs. Ref callbacks fire during commit (sync),
// but useMountEffect fires after paint — the observer can sneak in between.
const isLoadingRef = useRef(true);
const currentCheckIdRef = useRef(checkId);
const controllerRef = useRef<AbortController | null>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const totalCountRef = useRef<number | null>(null);
// Store latest values in refs so the fetch function always reads current values
// without being recreated on every render
const hasDateOrScanRef = useRef(hasDateOrScanFilter);
const filtersRef = useRef(filters);
const onSetResourcesRef = useRef(onSetResources);
const onAppendResourcesRef = useRef(onAppendResources);
const onSetLoadingRef = useRef(onSetLoading);
// Keep refs in sync with latest props
currentCheckIdRef.current = checkId;
hasDateOrScanRef.current = hasDateOrScanFilter;
filtersRef.current = filters;
@@ -85,6 +103,7 @@ export function useFindingGroupResources({
filters: filtersRef.current,
});
// Discard stale response if aborted (e.g. Strict Mode remount)
if (signal.aborted) {
return;
}
@@ -97,6 +116,10 @@ export function useFindingGroupResources({
const hasMore = page < totalPages;
totalCountRef.current = response?.meta?.pagination?.count ?? null;
// Commit the page number only after a successful (non-aborted) fetch.
// This prevents a premature pageRef increment from loadNextPage being
// permanently committed if a concurrent abort fires before fetchPage
// starts executing.
pageRef.current = page;
hasMoreRef.current = hasMore;
@@ -107,20 +130,27 @@ export function useFindingGroupResources({
}
} catch (error) {
if (!signal.aborted) {
console.error("Error fetching finding group resources:", error);
console.error("Error fetching resources:", error);
onSetLoadingRef.current(false);
}
} finally {
// Only release the loading guard if this fetch wasn't aborted.
// An aborted fetch (e.g. Strict Mode cleanup) must NOT reset the flag
// while a subsequent fetch from the remount is still in flight.
if (!signal.aborted) {
isLoadingRef.current = false;
}
}
}
// Fetch first page on mount — parent remounts via key-prop on checkId/filter changes
useMountEffect(() => {
const controller = new AbortController();
controllerRef.current = controller;
// Release the loading guard so fetchPage can proceed.
// This is synchronous with the fetchPage call below, so the observer
// cannot sneak in between these two lines.
isLoadingRef.current = false;
fetchPage(1, false, checkId, controller.signal);
@@ -137,13 +167,17 @@ export function useFindingGroupResources({
isLoadingRef.current ||
!signal ||
signal.aborted
) {
)
return;
}
// Pass the next page number as an argument without pre-committing
// pageRef.current. The fetchPage function commits pageRef.current = page
// only after a successful (non-aborted) response, eliminating the race
// where a concurrent abort would leave pageRef permanently incremented.
fetchPage(pageRef.current + 1, true, currentCheckIdRef.current, signal);
}
// IntersectionObserver callback
function handleIntersection(entries: IntersectionObserverEntry[]) {
const [entry] = entries;
if (entry.isIntersecting) {
@@ -151,6 +185,7 @@ export function useFindingGroupResources({
}
}
// Set up observer when sentinel node changes
function sentinelRef(node: HTMLDivElement | null) {
if (observerRef.current) {
observerRef.current.disconnect();
@@ -166,6 +201,7 @@ export function useFindingGroupResources({
}
}
/** Imperatively reset and re-fetch page 1 without changing deps. */
function refresh() {
controllerRef.current?.abort();
const controller = new AbortController();

View File

@@ -1,62 +0,0 @@
import { createDict } from "@/lib";
import type { FindingProps } from "@/types/components";
import type { FindingResourceRow } from "@/types/findings-table";
import type { ProviderType } from "@/types/providers";
interface JsonApiFindingResponse {
data?: FindingProps;
included?: Record<string, unknown>[];
}
export function expandFindingWithRelationships(
apiResponse: JsonApiFindingResponse | undefined,
): FindingProps | null {
if (!apiResponse?.data) {
return null;
}
const resourceDict = createDict("resources", apiResponse);
const scanDict = createDict("scans", apiResponse);
const providerDict = createDict("providers", apiResponse);
const finding = apiResponse.data;
const scan = scanDict[finding.relationships?.scan?.data?.id];
const resource =
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
const provider = providerDict[scan?.relationships?.provider?.data?.id];
return {
...finding,
relationships: { ...finding.relationships, scan, resource, provider },
} as FindingProps;
}
export function findingToFindingResourceRow(
finding: FindingProps,
): FindingResourceRow {
const resource = finding.relationships?.resource?.attributes;
const provider = finding.relationships?.provider?.attributes;
return {
id: finding.id,
rowType: "resource",
findingId: finding.id,
checkId: finding.attributes.check_id,
providerType: (provider?.provider || "aws") as ProviderType,
providerAlias: provider?.alias || "-",
providerUid: provider?.uid || "-",
resourceName: resource?.name || "-",
resourceType: resource?.type || "-",
resourceGroup: "-",
resourceUid: resource?.uid || "-",
service: resource?.service || "-",
region: resource?.region || "-",
severity: finding.attributes.severity,
status: finding.attributes.status,
delta: finding.attributes.delta,
isMuted: finding.attributes.muted,
mutedReason: finding.attributes.muted_reason,
firstSeenAt: finding.attributes.first_seen_at,
lastSeenAt: finding.attributes.updated_at,
};
}