Compare commits
2 Commits
chore/upda
...
fix/PROWLE
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd66c95cea | ||
|
|
645424242a |
23
.github/CODEOWNERS
vendored
@@ -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
|
||||
|
||||
115
.opencode/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||

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

|
||||
|
||||
## 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.
|
||||
|
||||
@@ -46,7 +46,8 @@ Search and retrieve official Prowler documentation:
|
||||
|
||||
The following diagram illustrates the Prowler MCP Server architecture and its integration points:
|
||||
|
||||

|
||||
<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
|
||||
|
||||
|
Before Width: | Height: | Size: 755 KiB |
|
Before Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 340 KiB |
|
Before Width: | Height: | Size: 410 KiB |
BIN
docs/images/lighthouse-architecture-dark.png
Normal file
|
After Width: | Height: | Size: 267 KiB |
BIN
docs/images/lighthouse-architecture-light.png
Normal file
|
After Width: | Height: | Size: 265 KiB |
@@ -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
|
||||
|
Before Width: | Height: | Size: 286 KiB |
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 348 KiB After Width: | Height: | Size: 268 KiB |
@@ -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
|
||||
|
Before Width: | Height: | Size: 371 KiB |
BIN
docs/images/prowler_mcp_schema_dark.png
Normal file
|
After Width: | Height: | Size: 328 KiB |
BIN
docs/images/prowler_mcp_schema_light.png
Normal file
|
After Width: | Height: | Size: 332 KiB |
@@ -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.
|
||||
|
||||

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

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

|
||||
|
||||
### 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.
|
||||
|
||||

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

|
||||
<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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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 == []
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@ export {
|
||||
getLatestResources,
|
||||
getMetadataInfo,
|
||||
getResourceById,
|
||||
getResourceDrawerData,
|
||||
getResourceEvents,
|
||||
getResources,
|
||||
} from "./resources";
|
||||
|
||||
@@ -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 ?? {},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
14
ui/components/findings/table/data-table-row-details.tsx
Normal 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} />;
|
||||
};
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
299
ui/components/findings/table/finding-detail.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
502
ui/components/findings/table/finding-detail.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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;
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from "./column-latest-findings";
|
||||
export * from "./column-new-findings-to-date";
|
||||
export * from "./skeleton-table-new-findings";
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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}");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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(");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
85
ui/components/resources/table/resource-detail.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
570
ui/hooks/use-infinite-resources.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||