From e7defe26edf2934def8b3626322db9b44506c34e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:44:19 +0200 Subject: [PATCH 01/43] build(deps): bump github/codeql-action from 1 to 4 (#1546) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 1 to 4. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v1...v4) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 69f06cdc2..93e85f913 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -45,7 +45,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -56,7 +56,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v1 + uses: github/codeql-action/autobuild@v4 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -70,4 +70,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v4 From ff987627361266b04a5b5fc4b55dd1531b21e505 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:45:41 +0200 Subject: [PATCH 02/43] build(deps): bump pilosus/action-pip-license-checker from 2 to 3 (#1550) Bumps [pilosus/action-pip-license-checker](https://github.com/pilosus/action-pip-license-checker) from 2 to 3. - [Release notes](https://github.com/pilosus/action-pip-license-checker/releases) - [Changelog](https://github.com/pilosus/action-pip-license-checker/blob/main/CHANGELOG.md) - [Commits](https://github.com/pilosus/action-pip-license-checker/compare/v2...v3) --- updated-dependencies: - dependency-name: pilosus/action-pip-license-checker dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/_node.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/_node.yml b/.github/workflows/_node.yml index 525e8a199..1e6e88827 100644 --- a/.github/workflows/_node.yml +++ b/.github/workflows/_node.yml @@ -137,7 +137,7 @@ jobs: if: ${{ inputs.check_packages_licenses }} id: license_check_report continue-on-error: true - uses: pilosus/action-pip-license-checker@v2 + uses: pilosus/action-pip-license-checker@v3 with: requirements: ${{ inputs.requirements_path }} external: ${{ inputs.working_directory }}/licenses.csv From eeda6b5a3cdce0470ea348270cd2444297976001 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:45:56 +0200 Subject: [PATCH 03/43] build(deps): bump actions/setup-python from 5 to 7 (#1549) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 7. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5...v7) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/_python.yml | 2 +- .github/workflows/create_python_cache.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/_python.yml b/.github/workflows/_python.yml index a7365c750..614b05c52 100644 --- a/.github/workflows/_python.yml +++ b/.github/workflows/_python.yml @@ -262,7 +262,7 @@ jobs: - name: Set up Python id: setup_python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version-file: "pyproject.toml" diff --git a/.github/workflows/create_python_cache.yaml b/.github/workflows/create_python_cache.yaml index 15e541437..ba7949736 100644 --- a/.github/workflows/create_python_cache.yaml +++ b/.github/workflows/create_python_cache.yaml @@ -34,7 +34,7 @@ jobs: - name: Set up Python id: setup_python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: "3.13" From d47f229ad545f733e6354771494d1ed6f04b1b5b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:48:25 +0200 Subject: [PATCH 04/43] build(deps): bump actions/upload-artifact from 4 to 7 (#1548) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/_python.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/_python.yml b/.github/workflows/_python.yml index 614b05c52..b342f66f5 100644 --- a/.github/workflows/_python.yml +++ b/.github/workflows/_python.yml @@ -460,7 +460,7 @@ jobs: - name: Upload coverage report as artifact if: inputs.use_coverage && inputs.upload_coverage - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: coverage-report-py${{ matrix.python_version }} path: ${{ inputs.working_directory }}/coverage.xml From d90439adaca7751346689cfe655594c6026ca7a9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:49:59 +0200 Subject: [PATCH 05/43] build(deps): bump library/nginx in /docker (#1545) Bumps library/nginx from 1.31.4-alpine to 1.31.5-alpine. --- updated-dependencies: - dependency-name: library/nginx dependency-version: 1.31.5-alpine dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docker/Dockerfile_nginx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile_nginx b/docker/Dockerfile_nginx index 73bfacd8d..2e00d7c89 100644 --- a/docker/Dockerfile_nginx +++ b/docker/Dockerfile_nginx @@ -1,4 +1,4 @@ -FROM library/nginx:1.31.4-alpine +FROM library/nginx:1.31.5-alpine ENV NGINX_LOG_DIR=/var/log/nginx From 0795368a3bd466deb3faa172af517aa436b5744e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:50:39 +0200 Subject: [PATCH 06/43] build(deps): bump actions/checkout from 2 to 7 (#1547) Bumps [actions/checkout](https://github.com/actions/checkout) from 2 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v2...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/_detect_changes.yml | 4 ++-- .github/workflows/_node.yml | 2 +- .github/workflows/_python.yml | 2 +- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/create_apt_cache.yaml | 2 +- .github/workflows/create_python_cache.yaml | 2 +- .github/workflows/dependency_review.yml | 2 +- .github/workflows/release.yml | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/_detect_changes.yml b/.github/workflows/_detect_changes.yml index 9e9b8a654..a4884e176 100644 --- a/.github/workflows/_detect_changes.yml +++ b/.github/workflows/_detect_changes.yml @@ -46,12 +46,12 @@ jobs: frontend: ${{steps.diff_check_frontend.outputs.frontend}} steps: - name: Check out PR target branch - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: ref: ${{ github.base_ref }} - name: Check out source branch latest commit - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: clean: false diff --git a/.github/workflows/_node.yml b/.github/workflows/_node.yml index 1e6e88827..7966cb746 100644 --- a/.github/workflows/_node.yml +++ b/.github/workflows/_node.yml @@ -97,7 +97,7 @@ jobs: language: ['javascript'] steps: - name: Check out latest commit for current branch - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/_python.yml b/.github/workflows/_python.yml index b342f66f5..f7fafc513 100644 --- a/.github/workflows/_python.yml +++ b/.github/workflows/_python.yml @@ -258,7 +258,7 @@ jobs: env: ${{ fromJson(inputs.env) }} steps: - name: Check out latest commit - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Python id: setup_python diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 93e85f913..fb3eddf92 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -37,7 +37,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v7 with: # We must fetch at least the immediate parents so that if this is # a pull request then we can checkout the head. diff --git a/.github/workflows/create_apt_cache.yaml b/.github/workflows/create_apt_cache.yaml index 9bd5fd730..ee1b2ec11 100644 --- a/.github/workflows/create_apt_cache.yaml +++ b/.github/workflows/create_apt_cache.yaml @@ -24,7 +24,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out latest commit on current branch - uses: actions/checkout@v4 + uses: actions/checkout@v7 # Remember to set the same APT requirements file path set before! - name: Install APT dependencies diff --git a/.github/workflows/create_python_cache.yaml b/.github/workflows/create_python_cache.yaml index ba7949736..fc6562a04 100644 --- a/.github/workflows/create_python_cache.yaml +++ b/.github/workflows/create_python_cache.yaml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out latest commit - uses: actions/checkout@v4 + uses: actions/checkout@v7 # Uncomment only if necessary #- name: Install system dependencies required by Python packages diff --git a/.github/workflows/dependency_review.yml b/.github/workflows/dependency_review.yml index 2e7df7389..d63f3dbd8 100644 --- a/.github/workflows/dependency_review.yml +++ b/.github/workflows/dependency_review.yml @@ -11,6 +11,6 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout Repository' - uses: actions/checkout@v3 + uses: actions/checkout@v7 - name: 'Dependency Review' uses: actions/dependency-review-action@v4 \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 01d592a5b..b310e9071 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: match: ${{ steps.check-tag.outputs.match }} version: ${{ steps.check-tag.outputs.version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Check version tag format id: check-tag From b6e37747d1a03cae67663b9bbea5647659304110 Mon Sep 17 00:00:00 2001 From: akshat patil Date: Mon, 7 Sep 2026 18:51:43 +0530 Subject: [PATCH 07/43] feat: add API docs links to the header Docs menu. Closes #1541 (#1553) --- frontend/src/constants/environment.js | 2 + frontend/src/layouts/AppHeader.jsx | 55 +++++++++++++---- frontend/tests/layouts/AppHeader.test.jsx | 73 +++++++++++++++++++++++ 3 files changed, 120 insertions(+), 10 deletions(-) create mode 100644 frontend/tests/layouts/AppHeader.test.jsx diff --git a/frontend/src/constants/environment.js b/frontend/src/constants/environment.js index d39d49808..5120edf52 100644 --- a/frontend/src/constants/environment.js +++ b/frontend/src/constants/environment.js @@ -1,5 +1,7 @@ export const GREEDYBEAR_DOCS_URL = "https://github.com/GreedyBear-Project/GreedyBear/wiki"; +export const GREEDYBEAR_API_SWAGGER_URL = "/api/schema/swagger-ui/"; +export const GREEDYBEAR_API_REDOC_URL = "/api/schema/redoc/"; // env variables // Vite uses import.meta.env, Jest uses process.env diff --git a/frontend/src/layouts/AppHeader.jsx b/frontend/src/layouts/AppHeader.jsx index 1bfded80e..0717d3c9d 100644 --- a/frontend/src/layouts/AppHeader.jsx +++ b/frontend/src/layouts/AppHeader.jsx @@ -6,6 +6,10 @@ import { Collapse, NavbarBrand, NavbarToggler, + UncontrolledDropdown, + DropdownToggle, + DropdownMenu, + DropdownItem, } from "reactstrap"; import { NavLink as RRNavLink } from "react-router-dom"; import { @@ -13,14 +17,20 @@ import { MdOutlineFeed, MdDashboard, MdTrendingUp, + MdDescription, } from "react-icons/md"; import { RiBookReadFill } from "react-icons/ri"; +import { SiGithub, SiSwagger } from "react-icons/si"; // lib import { NavLink } from "@greedybear/gb-ui"; // constants -import { GREEDYBEAR_DOCS_URL } from "../constants/environment"; +import { + GREEDYBEAR_DOCS_URL, + GREEDYBEAR_API_SWAGGER_URL, + GREEDYBEAR_API_REDOC_URL, +} from "../constants/environment"; // local import UserMenu from "./widget/UserMenu"; @@ -49,17 +59,42 @@ const guestLinks = ( ); const rightLinks = ( - - + + Docs - - + + + + + Wiki + + + + + Swagger UI + + + + Redoc + + + ); function AppHeader() { diff --git a/frontend/tests/layouts/AppHeader.test.jsx b/frontend/tests/layouts/AppHeader.test.jsx new file mode 100644 index 000000000..eb695644f --- /dev/null +++ b/frontend/tests/layouts/AppHeader.test.jsx @@ -0,0 +1,73 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; + +import AppHeader from "../../src/layouts/AppHeader"; +import { AUTHENTICATION_STATUSES } from "../../src/constants"; +import { + GREEDYBEAR_DOCS_URL, + GREEDYBEAR_API_SWAGGER_URL, + GREEDYBEAR_API_REDOC_URL, +} from "../../src/constants/environment"; + +// Mock useAuthStore so the header renders the guest links +// and never reaches into the real (persisted) store. +const mockUseAuthStore = vi.fn(); +vi.mock("../../src/stores", () => ({ + useAuthStore: (selector) => mockUseAuthStore(selector), +})); + +const renderHeader = () => + render( + + + , + ); + +// The dropdown items are only exposed to the accessibility tree +// once the menu is open, so every link assertion opens it first. +const openDocsMenu = async () => { + renderHeader(); + await userEvent.click(screen.getByText("Docs")); +}; + +describe("AppHeader docs menu", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseAuthStore.mockImplementation((selector) => + selector({ isAuthenticated: AUTHENTICATION_STATUSES.FALSE }), + ); + }); + + test("renders the Docs dropdown toggle", () => { + renderHeader(); + expect(screen.getByText("Docs")).toBeInTheDocument(); + }); + + test("links to the wiki and both API doc UIs", async () => { + await openDocsMenu(); + + const expectedLinks = [ + ["Wiki", GREEDYBEAR_DOCS_URL], + ["Swagger UI", GREEDYBEAR_API_SWAGGER_URL], + ["Redoc", GREEDYBEAR_API_REDOC_URL], + ]; + + expectedLinks.forEach(([label, href]) => { + const link = screen.getByRole("menuitem", { name: label }); + expect(link).toHaveAttribute("href", href); + }); + }); + + test("opens every docs link in a new tab safely", async () => { + await openDocsMenu(); + + ["Wiki", "Swagger UI", "Redoc"].forEach((label) => { + const link = screen.getByRole("menuitem", { name: label }); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + }); +}); From 671942c57c112d2ae880c3001409fae2999f1540 Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 7 Sep 2026 09:00:24 -0500 Subject: [PATCH 08/43] Payload quota error notification. Closes #1555 (#1558) Co-authored-by: joshuavetos --- greedybear/cronjobs/payload_extraction.py | 2 +- tests/test_payload_extraction.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/greedybear/cronjobs/payload_extraction.py b/greedybear/cronjobs/payload_extraction.py index e31299ccc..fc170e825 100644 --- a/greedybear/cronjobs/payload_extraction.py +++ b/greedybear/cronjobs/payload_extraction.py @@ -61,7 +61,7 @@ def run(self) -> None: for payload_meta in new_payloads: # Check disk usage before each download. if self._quarantine_usage_bytes() >= max_size_bytes: - self.log.warning(f"Quarantine directory has reached the {settings.MAX_QUARANTINE_SIZE_GB} GB limit. Stopping downloads.") + self.log.error(f"Quarantine directory has reached the {settings.MAX_QUARANTINE_SIZE_GB} GB limit. Stopping downloads.") break if self._download_and_store(client, server_url, payload_meta): diff --git a/tests/test_payload_extraction.py b/tests/test_payload_extraction.py index d1dbd7abe..185d29397 100644 --- a/tests/test_payload_extraction.py +++ b/tests/test_payload_extraction.py @@ -146,8 +146,10 @@ def test_stops_when_quarantine_limit_reached(self, mock_http_class, mock_usage): # Report disk usage above the limit (0.001 GB ~ 1,073,741 bytes). mock_usage.return_value = 2_000_000 - self.job.run() + with patch.object(self.job.log, "error") as mock_error: + self.job.run() + mock_error.assert_called_once_with("Quarantine directory has reached the 0.001 GB limit. Stopping downloads.") # No payload should have been downloaded. self.assertEqual(HoneypotPayload.objects.count(), 0) From b6883abbd7ef21d66bfb35c8bc35c23672d40475 Mon Sep 17 00:00:00 2001 From: Salma Kamel <133154635+rainnj@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:25:59 +0200 Subject: [PATCH 09/43] Document rationale for add_tags vs replace_tags_for_source. Closes #1559 (#1563) * Document rationale for add_tags vs replace_tags_for_source. Progresses #1551 * Fix indentation: replace tabs with spaces * Fix trailing whitespace --- greedybear/cronjobs/credential_reuse.py | 5 ++++- greedybear/cronjobs/extraction/strategies/tanner.py | 5 +++++ greedybear/cronjobs/reverse_dns.py | 4 ++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/greedybear/cronjobs/credential_reuse.py b/greedybear/cronjobs/credential_reuse.py index 84761a6ad..33c9a2689 100644 --- a/greedybear/cronjobs/credential_reuse.py +++ b/greedybear/cronjobs/credential_reuse.py @@ -52,7 +52,10 @@ def run(self) -> None: "value": "high_credential_reuse", } ) - + # Using add_tags instead of replace_tags_for_source because this + # job only processes new candidates each run and excludes ones + # already tagged. replace would delete previously found behavior + # tags just because those IPs aren't in today's batch. created = self.tag_repo.add_tags(SOURCE_NAME, tag_entries) self.log.info(f"Credential reuse detection complete: tagged {created} IPs") diff --git a/greedybear/cronjobs/extraction/strategies/tanner.py b/greedybear/cronjobs/extraction/strategies/tanner.py index 504f2f053..a00a25bff 100644 --- a/greedybear/cronjobs/extraction/strategies/tanner.py +++ b/greedybear/cronjobs/extraction/strategies/tanner.py @@ -103,6 +103,11 @@ def extract_from_hits(self, hits: list[dict]) -> None: self._classify_attacks(hits) tag_entries = [{"ioc_id": ioc_id, "key": "attack_type", "value": attack_type} for ioc_id, attack_type in self.attack_tags_set] + # Using add_tags instead of replace_tags_for_source because this + # strategy only processes the current extraction chunk (new + # hits) each run. replace would delete attack_type tags from + # previously processed attacks just because they aren't in + # today's chunk. self.attack_tags_added += self.tag_repo.add_tags(TANNER_SOURCE, tag_entries) self.log.info( diff --git a/greedybear/cronjobs/reverse_dns.py b/greedybear/cronjobs/reverse_dns.py index d80f315c4..bea3e9841 100644 --- a/greedybear/cronjobs/reverse_dns.py +++ b/greedybear/cronjobs/reverse_dns.py @@ -84,6 +84,10 @@ def run(self) -> None: updated_count = self.ioc_repo.bulk_update_ioc_reputation(matched_ips, IpReputation.MASS_SCANNER.value) self.log.info(f"Marked {updated_count} IPs as mass scanners via rDNS") + # Using add_tags instead of replace_tags_for_source because this job only checks + # new candidate IPs each run and excludes ones already + # tagged. replace would delete previously found ptr records + # just because those IPs aren't in today's batch. created_count = self.tag_repo.add_tags(SOURCE_NAME, tag_entries) self.log.info(f"Reverse DNS check completed. Checked {len(ptr_results)} IPs, created {created_count} tags, {len(matched_ips)} matched mass scanners") From a77b0195acaf4381c7742dd465c31ae3e2cb3c5b Mon Sep 17 00:00:00 2001 From: Salma Kamel <133154635+rainnj@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:05:50 +0200 Subject: [PATCH 10/43] Add usage guide to add_tags docstring. Closes #1559 (#1564) --- greedybear/cronjobs/repositories/tag.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/greedybear/cronjobs/repositories/tag.py b/greedybear/cronjobs/repositories/tag.py index bf4904758..9c43a49f9 100644 --- a/greedybear/cronjobs/repositories/tag.py +++ b/greedybear/cronjobs/repositories/tag.py @@ -58,6 +58,14 @@ def add_tags(self, source: str, tag_entries: list[dict]) -> int: leaves previously stored tags intact. Suitable for sources that build up results over many runs (e.g., reverse DNS lookups). + Use this when the calling job only processes a subset of new + candidates each run (typically by excluding IOCs already tagged + by that source in its query). replace_tags_for_source would be + incorrect here, since it deletes all existing tags for the source + before writing, which would wipe out tags from previous runs for + IOCs that simply weren't part of the current batch. + + Args: source: Source name (e.g., "rdns"). tag_entries: List of dicts with keys: ioc_id, key, value. From 946c7add31bfd128cabe396a319edd76fb0a8c4f Mon Sep 17 00:00:00 2001 From: akshat patil Date: Tue, 8 Sep 2026 19:38:59 +0530 Subject: [PATCH 11/43] fix: normalize SHA256 case in payload extraction. Closes #1556 (#1565) * fix: normalize SHA256 case in payload extraction. Closes #1556 * refactor: inline .lower() instead of a wrapper function --- greedybear/cronjobs/payload_extraction.py | 20 +++- tests/test_payload_extraction.py | 120 ++++++++++++++++++++++ 2 files changed, 136 insertions(+), 4 deletions(-) diff --git a/greedybear/cronjobs/payload_extraction.py b/greedybear/cronjobs/payload_extraction.py index fc170e825..2545745d6 100644 --- a/greedybear/cronjobs/payload_extraction.py +++ b/greedybear/cronjobs/payload_extraction.py @@ -7,6 +7,7 @@ import requests from django.conf import settings from django.core.files.base import ContentFile +from django.db.models.functions import Lower from greedybear.cronjobs.base import Cronjob from greedybear.cronjobs.http_client import HttpClient @@ -126,15 +127,24 @@ def _deduplicate(self, payloads: list[dict]) -> list[dict]: Returns: list[dict]: Only the unique payloads not yet stored locally. """ - incoming_hashes = {p["sha256"] for p in payloads if "sha256" in p} - existing_hashes = set(HoneypotPayload.objects.filter(sha256__in=incoming_hashes).values_list("sha256", flat=True)) + # HoneypotPayload is unique on Lower("sha256"), so compare case-insensitively + # on both sides. Rows written before this normalization existed may still hold + # a mixed-case hash; a case-sensitive lookup would miss them and let the insert + # through to fail on the constraint instead. + incoming_hashes = {p["sha256"].lower() for p in payloads if "sha256" in p} + existing_hashes = set( + HoneypotPayload.objects.annotate(sha256_lower=Lower("sha256")).filter(sha256_lower__in=incoming_hashes).values_list("sha256_lower", flat=True) + ) new_hashes = incoming_hashes - existing_hashes self.log.debug(f"Deduplication: {len(incoming_hashes)} incoming, {len(existing_hashes)} existing, {len(new_hashes)} new.") # Keep only the first occurrence of each sha256 to avoid IntegrityError. + # Hashes are compared normalized, so entries differing only in case + # collapse into one instead of colliding on the constraint. seen = set() unique = [] for p in payloads: - sha = p.get("sha256") + raw_sha = p.get("sha256") + sha = raw_sha.lower() if raw_sha else None if sha in new_hashes and sha not in seen: seen.add(sha) unique.append(p) @@ -161,7 +171,9 @@ def _download_and_store(self, client: HttpClient, server_url: str, payload_meta: Returns: bool: True if download and storage succeeded, False otherwise. """ - sha256 = payload_meta["sha256"] + # Lower-cased like in _deduplicate, so the stored row and the quarantine + # filename always use the same case as every other writer. + sha256 = payload_meta["sha256"].lower() locator = payload_meta.get("locator", "") if not locator: diff --git a/tests/test_payload_extraction.py b/tests/test_payload_extraction.py index 185d29397..d10d963c6 100644 --- a/tests/test_payload_extraction.py +++ b/tests/test_payload_extraction.py @@ -329,6 +329,126 @@ def test_quarantine_usage_returns_zero_for_missing_dir(self): result = self.job._quarantine_usage_bytes() self.assertEqual(result, 0) + # SHA256 case normalization (see issue #1556). + # HoneypotPayload is unique on Lower("sha256"), so a hash that differs only + # in case must be treated as a duplicate. Before the fix, the case-sensitive + # lookup missed it, the insert went ahead and the unique constraint aborted + # the whole run. + + @override_settings( + TPOT_PAYLOAD_SERVER_URL="http://payload-server:8000", + TPOT_PAYLOAD_SERVER_API_KEY="", + MAX_QUARANTINE_SIZE_GB=5, + ) + @patch("greedybear.cronjobs.payload_extraction.PayloadExtractionJob._quarantine_usage_bytes") + @patch("greedybear.cronjobs.payload_extraction.HttpClient") + def test_uppercase_hash_matches_existing_lowercase_row(self, mock_http_class, mock_usage): + """An uppercase hash from the server must not re-insert a known payload.""" + HoneypotPayload.objects.create(sha256="a" * 64) + + mock_client = MagicMock() + mock_http_class.return_value.__enter__ = Mock(return_value=mock_client) + mock_http_class.return_value.__exit__ = Mock(return_value=False) + + mock_metadata_resp = Mock() + mock_metadata_resp.json.return_value = [ + {"sha256": "A" * 64, "locator": "cowrie/aaa"}, + ] + mock_client.get.return_value = mock_metadata_resp + mock_usage.return_value = 0 + + self.job.run() + + # Recognised as a duplicate: nothing inserted, no download attempted. + self.assertEqual(HoneypotPayload.objects.count(), 1) + self.assertEqual(mock_client.get.call_count, 1) + + @override_settings( + TPOT_PAYLOAD_SERVER_URL="http://payload-server:8000", + TPOT_PAYLOAD_SERVER_API_KEY="", + MAX_QUARANTINE_SIZE_GB=5, + ) + @patch("greedybear.cronjobs.payload_extraction.PayloadExtractionJob._quarantine_usage_bytes") + @patch("greedybear.cronjobs.payload_extraction.HttpClient") + def test_lowercase_hash_matches_existing_uppercase_row(self, mock_http_class, mock_usage): + """Rows stored in upper case before the fix must still be matched.""" + HoneypotPayload.objects.create(sha256="B" * 64) + + mock_client = MagicMock() + mock_http_class.return_value.__enter__ = Mock(return_value=mock_client) + mock_http_class.return_value.__exit__ = Mock(return_value=False) + + mock_metadata_resp = Mock() + mock_metadata_resp.json.return_value = [ + {"sha256": "b" * 64, "locator": "cowrie/bbb"}, + ] + mock_client.get.return_value = mock_metadata_resp + mock_usage.return_value = 0 + + self.job.run() + + self.assertEqual(HoneypotPayload.objects.count(), 1) + self.assertEqual(mock_client.get.call_count, 1) + + @override_settings( + TPOT_PAYLOAD_SERVER_URL="http://payload-server:8000", + TPOT_PAYLOAD_SERVER_API_KEY="", + MAX_QUARANTINE_SIZE_GB=5, + ) + @patch("greedybear.cronjobs.payload_extraction.PayloadExtractionJob._quarantine_usage_bytes") + @patch("greedybear.cronjobs.payload_extraction.HttpClient") + def test_new_payload_is_stored_lower_cased(self, mock_http_class, mock_usage): + """A new uppercase hash should be persisted in lower case.""" + mock_client = MagicMock() + mock_http_class.return_value.__enter__ = Mock(return_value=mock_client) + mock_http_class.return_value.__exit__ = Mock(return_value=False) + + mock_metadata_resp = Mock() + mock_metadata_resp.json.return_value = [ + {"sha256": "C" * 64, "locator": "cowrie/ccc"}, + ] + mock_download_resp = Mock() + mock_download_resp.content = b"\xde\xad" + mock_client.get.side_effect = [mock_metadata_resp, mock_download_resp] + mock_usage.return_value = 0 + + self.job.run() + + self.assertEqual(HoneypotPayload.objects.count(), 1) + payload = HoneypotPayload.objects.get() + self.assertEqual(payload.sha256, "c" * 64) + self.assertIn("c" * 64, payload.payload_file.name) + + @override_settings( + TPOT_PAYLOAD_SERVER_URL="http://payload-server:8000", + TPOT_PAYLOAD_SERVER_API_KEY="", + MAX_QUARANTINE_SIZE_GB=5, + ) + @patch("greedybear.cronjobs.payload_extraction.PayloadExtractionJob._quarantine_usage_bytes") + @patch("greedybear.cronjobs.payload_extraction.HttpClient") + def test_same_hash_in_different_cases_within_one_batch(self, mock_http_class, mock_usage): + """Two entries differing only in case must collapse into a single insert.""" + mock_client = MagicMock() + mock_http_class.return_value.__enter__ = Mock(return_value=mock_client) + mock_http_class.return_value.__exit__ = Mock(return_value=False) + + mock_metadata_resp = Mock() + mock_metadata_resp.json.return_value = [ + {"sha256": "d" * 64, "locator": "cowrie/ddd"}, + {"sha256": "D" * 64, "locator": "cowrie/DDD"}, + ] + mock_download_resp = Mock() + mock_download_resp.content = b"\xbe\xef" + mock_client.get.side_effect = [mock_metadata_resp, mock_download_resp] + mock_usage.return_value = 0 + + self.job.run() + + self.assertEqual(HoneypotPayload.objects.count(), 1) + self.assertEqual(HoneypotPayload.objects.get().sha256, "d" * 64) + # One metadata request plus exactly one download. + self.assertEqual(mock_client.get.call_count, 2) + class TestExtractAllPayloadIntegration(CustomTestCase): """Test that extract_all calls extract_honeypot_payloads.""" From fb3b61cafd944260eff340dca3d7e66d0f1c4d06 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:40:55 +0200 Subject: [PATCH 12/43] build(deps): bump actions/setup-node from 4 to 7 (#1577) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 7. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/_node.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/_node.yml b/.github/workflows/_node.yml index 7966cb746..5c81061e0 100644 --- a/.github/workflows/_node.yml +++ b/.github/workflows/_node.yml @@ -100,7 +100,7 @@ jobs: uses: actions/checkout@v7 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: ${{ matrix.node_version }} cache: 'npm' From 62d859d8928b8e23f68234edccefdc564915b287 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:41:09 +0200 Subject: [PATCH 13/43] build(deps): bump actions/dependency-review-action from 4 to 5 (#1576) Bumps [actions/dependency-review-action](https://github.com/actions/dependency-review-action) from 4 to 5. - [Release notes](https://github.com/actions/dependency-review-action/releases) - [Commits](https://github.com/actions/dependency-review-action/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/dependency-review-action dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/dependency_review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dependency_review.yml b/.github/workflows/dependency_review.yml index d63f3dbd8..affa53fda 100644 --- a/.github/workflows/dependency_review.yml +++ b/.github/workflows/dependency_review.yml @@ -13,4 +13,4 @@ jobs: - name: 'Checkout Repository' uses: actions/checkout@v7 - name: 'Dependency Review' - uses: actions/dependency-review-action@v4 \ No newline at end of file + uses: actions/dependency-review-action@v5 \ No newline at end of file From b236b3332eba5be48a64707d709d1d95d4348cf8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:41:23 +0200 Subject: [PATCH 14/43] build(deps-dev): bump ruff from 0.16.5 to 0.16.6 (#1574) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.16.5 to 0.16.6. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.16.5...0.16.6) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.16.6 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 44 ++++++++++++++++++++++---------------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d47517ab2..38126a4d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,7 @@ test = [ "django-test-migrations==1.6.0", ] lint = [ - "ruff==0.16.5", + "ruff==0.16.6", ] [tool.uv] diff --git a/uv.lock b/uv.lock index 016d15e55..5e95ff65d 100644 --- a/uv.lock +++ b/uv.lock @@ -573,7 +573,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [{ name = "django-watchfiles", specifier = "~=1.4" }] -lint = [{ name = "ruff", specifier = "==0.16.5" }] +lint = [{ name = "ruff", specifier = "==0.16.6" }] test = [ { name = "coverage", specifier = "==7.16.0" }, { name = "django-test-migrations", specifier = "==1.6.0" }, @@ -881,27 +881,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" }, - { url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" }, - { url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" }, - { url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" }, - { url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" }, - { url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" }, - { url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" }, - { url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" }, - { url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" }, - { url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" }, - { url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" }, - { url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" }, - { url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" }, - { url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" }, - { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, +version = "0.16.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/7c/6adb35d70e7c027e308274557901c7e00fb3407750faf3620c184ae058cb/ruff-0.16.6.tar.gz", hash = "sha256:dcf8a73d2ff77e99dde91244b4da16feba7f14e6beeb4015dee7c5a909e99050", size = 4921251, upload-time = "2026-09-03T16:57:29.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/28/9cc1b79639e284ec103f43c88c644db4eb58cbd0ea1ca11f1193435369ac/ruff-0.16.6-py3-none-linux_armv6l.whl", hash = "sha256:61c368c26bf8e973e5ab14a2772de587bc068ea3f9a277f673380749b4898fb8", size = 10015638, upload-time = "2026-09-03T16:56:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/627d342ef727ea7794edf74fe23d60a074b02c3acc2e9436684e782286ca/ruff-0.16.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ecf4f068e2e123e43a26e9db4e19524cc56563912404e83bbfca375757e45a32", size = 10220762, upload-time = "2026-09-03T16:56:44.681Z" }, + { url = "https://files.pythonhosted.org/packages/43/d9/b75668ce41e4c8d073d18d6d08672ba6906ce45d5c06ea4fdb2e84ce3853/ruff-0.16.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99b62ea33baf130f50368798d841f0d95527b6d817bf31817b65dd058f1d314c", size = 9835082, upload-time = "2026-09-03T16:56:47.142Z" }, + { url = "https://files.pythonhosted.org/packages/99/97/123ab10b05cde889c107c20f5a9774955104b5552796a2a8584b089ae8eb/ruff-0.16.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fbf89013f2bb3f6835a6038ff658dc8a1b38c98dc8e724b964168ad4e881876", size = 9949304, upload-time = "2026-09-03T16:56:49.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/58/a4a2c59dd2e5b85929c912d9cac3056eb9ee8c7e75e9b9fe3e109174966b/ruff-0.16.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56a67065e22efa6bc4d498299d3bb06c0c90aace8fac2068b5a12f9dc4d8d51d", size = 9840612, upload-time = "2026-09-03T16:56:52.368Z" }, + { url = "https://files.pythonhosted.org/packages/61/6a/ff8c8626a786c4f49d48ced4a752dadbca65f5263005f9c2416578194694/ruff-0.16.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e25cc89174874b176a157e4428d66761c2c0c006654419bf384f967f361ff1b1", size = 10543465, upload-time = "2026-09-03T16:56:55.089Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bb/c47535923365f337b82e28192e4e9eef2176511007cfd99a62fc22df5dad/ruff-0.16.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700580ed5303723cb3c11c2f1d2a8913ce77b7ea86646dddb887f5417a9ba70", size = 11267576, upload-time = "2026-09-03T16:56:57.791Z" }, + { url = "https://files.pythonhosted.org/packages/ba/50/e5119a5212b5cd63b51e1f4b25e7bd636a6668fc069a3160b108ad7e3c16/ruff-0.16.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15f1d0b6e165a6e56567befb6629f8209271311d990bae0f37e6d065035ef5f3", size = 10781993, upload-time = "2026-09-03T16:57:00.666Z" }, + { url = "https://files.pythonhosted.org/packages/8b/98/083d8b4ef3c51a0d19db84367791cbe9f44e4b53343d19dfa83556e1cd9a/ruff-0.16.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d72c591a96986ee4268860e2b7235082129ca5e4cb9cbba653a4b57c11893757", size = 10317748, upload-time = "2026-09-03T16:57:03.428Z" }, + { url = "https://files.pythonhosted.org/packages/9a/29/68f7ff2c5ad95f19f00627ac2de95644e25fe47371ea60b2db1fd952315e/ruff-0.16.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65a006baa18f33324325814c864daef03541d51564b98c517610ea756ab7003e", size = 10540096, upload-time = "2026-09-03T16:57:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f9/79a8f6de85968641d68a7863aeec577551924ef066a990a48ff93167beab/ruff-0.16.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cd02a7bf1a21a8735228a3e8c95a9dc5cf86bd2a52194f4aaae2a5755b4de0f4", size = 10100494, upload-time = "2026-09-03T16:57:09.194Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e8/b81a22d9b90c00b892ccf2fa2ac36fa95de4c13ab85aea3e73795cfe4651/ruff-0.16.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:31b36f1e5ad85e0737f09d2be4e512e2e283583c14015da3b9dc07359ac0fc88", size = 9843663, upload-time = "2026-09-03T16:57:12.168Z" }, + { url = "https://files.pythonhosted.org/packages/39/aa/54f516ec5e5a11c4afdceb1c454ebb054ffb96e4f4a1705580b4346abd35/ruff-0.16.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61029b4ab4aa723fd3064fab96b1d814492596bf0c792679fffcbde1e1679953", size = 10282461, upload-time = "2026-09-03T16:57:15.077Z" }, + { url = "https://files.pythonhosted.org/packages/52/0b/38d0aa8aa32372b96dc44f97b22e576c4147808271aab7b2cb1e353d4445/ruff-0.16.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ac8998457832c2061709d900856b7ad271dace0cb41f346588d540162bfa718", size = 10728808, upload-time = "2026-09-03T16:57:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/9e274e24eeb027640ffc7442f21239f16d17f47acec15ae34f32e03a5c79/ruff-0.16.6-py3-none-win32.whl", hash = "sha256:0b87d9d16fcb63e8018423ca1d50b7260f15cb2da33e30db4baad4183a948c25", size = 10049212, upload-time = "2026-09-03T16:57:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" }, ] [[package]] From a6893defda5e1c415c5455ccfafc87024ba8c644 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:41:40 +0200 Subject: [PATCH 15/43] build(deps): bump softprops/action-gh-release from 2 to 3 (#1575) Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b310e9071..9f60d73b4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,7 +43,7 @@ jobs: - name: Create release if: steps.check-tag.outputs.match == 'true' - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ steps.check-tag.outputs.version }} name: Version ${{ steps.check-tag.outputs.version }} From cf3322e4ee9cb319b36f49eab3be09b9b0ce57a7 Mon Sep 17 00:00:00 2001 From: Rohit Padala <97235434+kikiscodedeliveryservice@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:38:10 +0530 Subject: [PATCH 16/43] feat: Link HoneypotPayload to Cowrie sessions and attacker IOCs by SHA256. Closes #1509 (#1552) * feat: Link HoneypotPayload to Cowrie sessions and attacker IOCs by SHA256. Closes #1509 * fix: use lower-cased shasum matching instead of __iexact --------- Co-authored-by: Rohit Padala --- api/serializers/payloads.py | 12 +++ api/views/payloads.py | 2 +- .../cronjobs/extraction/strategies/cowrie.py | 11 ++- greedybear/cronjobs/payload_extraction.py | 14 ++- greedybear/cronjobs/repositories/__init__.py | 1 + greedybear/cronjobs/repositories/payload.py | 61 +++++++++++++ tests/test_cowrie_extraction.py | 32 ++++++- tests/test_payload_extraction.py | 65 +++++++++++++- tests/test_payload_repository.py | 90 +++++++++++++++++++ tests/test_serializers.py | 37 +++++++- 10 files changed, 318 insertions(+), 7 deletions(-) create mode 100644 greedybear/cronjobs/repositories/payload.py create mode 100644 tests/test_payload_repository.py diff --git a/api/serializers/payloads.py b/api/serializers/payloads.py index 0b2b658d9..d4dfb8410 100644 --- a/api/serializers/payloads.py +++ b/api/serializers/payloads.py @@ -12,6 +12,13 @@ class HoneypotPayloadSerializer(serializers.ModelSerializer): slug_field="name", help_text="Names of the honeypots that captured this payload.", ) + iocs = serializers.SlugRelatedField( + many=True, + read_only=True, + slug_field="name", + help_text="Attacker IPs (or other IOCs) linked to this payload.", + ) + cowrie_sessions = serializers.SerializerMethodField(help_text="IDs of the Cowrie sessions that transferred this payload, as hex strings.") class Meta: model = HoneypotPayload @@ -23,6 +30,8 @@ class Meta: "mime_type", "size", "source_honeypots", + "iocs", + "cowrie_sessions", ] extra_kwargs = { "id": {"help_text": "Unique identifier of the payload."}, @@ -32,3 +41,6 @@ class Meta: "mime_type": {"help_text": "MIME type of the payload file."}, "size": {"help_text": "Size of the payload in bytes."}, } + + def get_cowrie_sessions(self, obj: HoneypotPayload) -> list[str]: + return [f"{session.session_id:x}" for session in obj.cowrie_sessions.all()] diff --git a/api/views/payloads.py b/api/views/payloads.py index 7a3c8ae85..3a2dc7e56 100644 --- a/api/views/payloads.py +++ b/api/views/payloads.py @@ -49,7 +49,7 @@ class HoneypotPayloadViewSet(viewsets.ReadOnlyModelViewSet): :class:`~api.permissions.IsThreatResearcherOrAdmin`. """ - queryset = HoneypotPayload.objects.prefetch_related("source_honeypots").order_by("-id") + queryset = HoneypotPayload.objects.prefetch_related("source_honeypots", "iocs", "cowrie_sessions").order_by("-id") serializer_class = HoneypotPayloadSerializer permission_classes = [IsAuthenticated] lookup_field = "sha256" diff --git a/greedybear/cronjobs/extraction/strategies/cowrie.py b/greedybear/cronjobs/extraction/strategies/cowrie.py index a5ed7e18d..e60848af7 100644 --- a/greedybear/cronjobs/extraction/strategies/cowrie.py +++ b/greedybear/cronjobs/extraction/strategies/cowrie.py @@ -15,6 +15,7 @@ from greedybear.cronjobs.repositories import ( CowrieSessionRepository, IocRepository, + PayloadRepository, SensorRepository, ) from greedybear.models import IOC, CommandSequence, CowrieSession @@ -60,8 +61,9 @@ class CowrieExtractionStrategy(BaseExtractionStrategy): Extracts scanner IPs, payload URLs from login attempts and file downloads, and session data including credentials and command - sequences. Links related IOCs (scanners to download URLs) and - deduplicates command sequences by hash. + sequences. Links related IOCs (scanners to download URLs), links file + transfers to any already-downloaded HoneypotPayload, and deduplicates + command sequences by hash. """ def __init__( @@ -70,9 +72,11 @@ def __init__( ioc_repo: IocRepository, sensor_repo: SensorRepository, session_repo: CowrieSessionRepository = None, + payload_repo: PayloadRepository = None, ): super().__init__(honeypot, ioc_repo, sensor_repo) self.session_repo = session_repo or CowrieSessionRepository() + self.payload_repo = payload_repo or PayloadRepository() self.payloads_in_message = 0 self.added_url_downloads = 0 @@ -275,6 +279,9 @@ def _process_session_hit(self, session_record: CowrieSession, hit: dict, ioc: IO timestamp=timestamp, ) + if self.payload_repo.link_payload_to_session(session_record, shasum): + self.log.info(f"linked existing payload {shasum[:8]}... to session {session_record.session_id:x}") + session_record.interaction_count += 1 def _deduplicate_command_sequence(self, session: CowrieSession) -> bool: diff --git a/greedybear/cronjobs/payload_extraction.py b/greedybear/cronjobs/payload_extraction.py index 2545745d6..8e9842898 100644 --- a/greedybear/cronjobs/payload_extraction.py +++ b/greedybear/cronjobs/payload_extraction.py @@ -11,6 +11,7 @@ from greedybear.cronjobs.base import Cronjob from greedybear.cronjobs.http_client import HttpClient +from greedybear.cronjobs.repositories import PayloadRepository from greedybear.models import HoneypotPayload @@ -26,6 +27,8 @@ class PayloadExtractionJob(Cronjob): 3. Checks quarantine disk usage against MAX_QUARANTINE_SIZE_GB before downloading. 4. Downloads new payload files via ``/api/v1/payloads/download/{locator}`` and saves them via QuarantineStorage. + 5. Links each downloaded payload to any Cowrie sessions that already + transferred a file with the same SHA256. """ # Timeout for the metadata listing request (seconds). @@ -33,6 +36,10 @@ class PayloadExtractionJob(Cronjob): # Timeout for individual file download requests (seconds). DOWNLOAD_TIMEOUT = 120 + def __init__(self, payload_repo: PayloadRepository = None): + super().__init__() + self.payload_repo = payload_repo if payload_repo is not None else PayloadRepository() + def run(self) -> None: server_url = settings.TPOT_PAYLOAD_SERVER_URL if not server_url: @@ -161,7 +168,8 @@ def _quarantine_usage_bytes(self) -> int: def _download_and_store(self, client: HttpClient, server_url: str, payload_meta: dict) -> bool: """ - Download a single payload file and create its database record. + Download a single payload file, create its database record, and link + it to any Cowrie sessions that already transferred the same file. Args: client: HttpClient instance. @@ -208,4 +216,8 @@ def _download_and_store(self, client: HttpClient, server_url: str, payload_meta: payload_obj.save() self.log.info(f"Stored new payload {sha256[:12]}… ({len(file_content)} bytes).") + + linked = self.payload_repo.link_sessions_to_payload(payload_obj) + if linked: + self.log.info(f"Linked payload {sha256[:12]}… to {linked} cowrie session(s).") return True diff --git a/greedybear/cronjobs/repositories/__init__.py b/greedybear/cronjobs/repositories/__init__.py index 3ac4f29fe..e9f8db64f 100644 --- a/greedybear/cronjobs/repositories/__init__.py +++ b/greedybear/cronjobs/repositories/__init__.py @@ -6,6 +6,7 @@ from greedybear.cronjobs.repositories.firehol import * from greedybear.cronjobs.repositories.ioc import * from greedybear.cronjobs.repositories.mass_scanner import * +from greedybear.cronjobs.repositories.payload import * from greedybear.cronjobs.repositories.sensor import * from greedybear.cronjobs.repositories.statistics import * from greedybear.cronjobs.repositories.tag import * diff --git a/greedybear/cronjobs/repositories/payload.py b/greedybear/cronjobs/repositories/payload.py new file mode 100644 index 000000000..9a08b7633 --- /dev/null +++ b/greedybear/cronjobs/repositories/payload.py @@ -0,0 +1,61 @@ +import logging + +from django.db.models.functions import Lower + +from greedybear.models import CowrieSession, HoneypotPayload + + +class PayloadRepository: + """ + Repository joining HoneypotPayload records with the CowrieSession(s) that + transferred the same file, matched by SHA256 hash. + + ExtractionJob (Cowrie) and PayloadExtractionJob run independently and can + complete in either order within the same tick, so this repository is used + from both directions: once a payload is downloaded, and once a Cowrie file + transfer is recorded, each side looks for a match already stored by the + other. + """ + + def __init__(self): + self.log = logging.getLogger(f"{__name__}.{self.__class__.__name__}") + + def link_sessions_to_payload(self, payload: HoneypotPayload) -> int: + """ + Find Cowrie sessions that transferred a file matching this payload's + SHA256, and link them (and their source IOCs) to the payload. + + Args: + payload: HoneypotPayload instance to link, already saved. + + Returns: + Number of sessions linked. + """ + # assumption: Cowrie always emits lowercase hex for shasum (hashlib.hexdigest()), and payload.sha256 is + # always stored in lowercase too, hence this plain comparison works with CowrieFileTransfer's shasum index. + sessions = CowrieSession.objects.filter(file_transfers__shasum=payload.sha256.lower()).select_related("source") + linked = 0 + for session in sessions: + payload.cowrie_sessions.add(session) + payload.iocs.add(session.source) + linked += 1 + return linked + + def link_payload_to_session(self, session: CowrieSession, shasum: str) -> bool: + """ + Find a HoneypotPayload matching this file transfer's SHA256, and link + it (and the session's source IOC) to the session. + + Args: + session: CowrieSession instance that transferred the file. + shasum: SHA256 checksum of the transferred file. + + Returns: + True if a matching payload was found and linked. + """ + payload = HoneypotPayload.objects.annotate(sha256_lower=Lower("sha256")).filter(sha256_lower=shasum.lower()).first() + if payload is None: + return False + payload.cowrie_sessions.add(session) + payload.iocs.add(session.source) + return True diff --git a/tests/test_cowrie_extraction.py b/tests/test_cowrie_extraction.py index f2aa16d18..6f92212bd 100644 --- a/tests/test_cowrie_extraction.py +++ b/tests/test_cowrie_extraction.py @@ -13,7 +13,7 @@ normalize_credential_field, parse_url_hostname, ) -from greedybear.models import CommandSequence +from greedybear.models import CommandSequence, HoneypotPayload from tests import ExtractionTestCase @@ -100,12 +100,15 @@ def setUp(self): self.mock_ioc_repo = Mock() self.mock_sensor_repo = Mock() self.mock_session_repo = Mock() + self.mock_payload_repo = Mock() + self.mock_payload_repo.link_payload_to_session.return_value = False self.strategy = CowrieExtractionStrategy( "Cowrie", self.mock_ioc_repo, self.mock_sensor_repo, self.mock_session_repo, + self.mock_payload_repo, ) self.strategy.ioc_processor = Mock() @@ -323,8 +326,33 @@ def test_process_session_hit_file_download_creates_transfer(self): outfile="/data/cowrie/downloads/bad.exe", timestamp=datetime(2023, 1, 1, 10, 0, 4), ) + self.mock_payload_repo.link_payload_to_session.assert_called_once_with(session_record, "abc123def456") self.assertEqual(session_record.interaction_count, 1) + def test_process_session_hit_file_download_links_real_payload(self): + """Test that the strategy's real PayloadRepository links a matching payload.""" + strategy = CowrieExtractionStrategy( + "Cowrie", + self.mock_ioc_repo, + self.mock_sensor_repo, + self.mock_session_repo, + ) + payload = HoneypotPayload.objects.create(sha256="abc123def456") + + hit = { + "eventid": "cowrie.session.file_download", + "timestamp": "2023-01-01T10:00:04", + "shasum": "abc123def456", + "url": "http://malware.com/bad.exe", + "outfile": "/data/cowrie/downloads/bad.exe", + } + ioc = Mock(name="1.2.3.4") + + strategy._process_session_hit(self.cowrie_session, hit, ioc) + + self.assertIn(self.cowrie_session, payload.cowrie_sessions.all()) + self.assertIn(self.cowrie_session.source, payload.iocs.all()) + def test_process_session_hit_file_upload_creates_transfer(self): """Test processing of file upload event creates file transfer.""" session_record = Mock() @@ -348,6 +376,7 @@ def test_process_session_hit_file_upload_creates_transfer(self): outfile="/var/lib/cowrie/downloads/deadbeef123456", timestamp=datetime(2023, 1, 1, 10, 0, 4), ) + self.mock_payload_repo.link_payload_to_session.assert_called_once_with(session_record, "deadbeef123456") self.assertEqual(session_record.interaction_count, 1) def test_process_session_hit_file_upload_without_shasum(self): @@ -367,6 +396,7 @@ def test_process_session_hit_file_upload_without_shasum(self): self.strategy._process_session_hit(session_record, hit, ioc) self.mock_session_repo.get_or_create_file_transfer.assert_not_called() + self.mock_payload_repo.link_payload_to_session.assert_not_called() self.assertEqual(session_record.interaction_count, 1) def test_add_fks_both_exist(self): diff --git a/tests/test_payload_extraction.py b/tests/test_payload_extraction.py index d10d963c6..4de59c137 100644 --- a/tests/test_payload_extraction.py +++ b/tests/test_payload_extraction.py @@ -2,9 +2,10 @@ import requests from django.test import override_settings +from django.utils import timezone from greedybear.cronjobs.payload_extraction import PayloadExtractionJob -from greedybear.models import HoneypotPayload +from greedybear.models import CowrieFileTransfer, HoneypotPayload from . import CustomTestCase @@ -94,6 +95,68 @@ def test_downloads_new_payload(self, mock_http_class, mock_usage): download_call = mock_client.get.call_args_list[1] self.assertIn("/api/v1/payloads/download/cowrie/aaa", download_call[0][0]) + @override_settings( + TPOT_PAYLOAD_SERVER_URL="http://payload-server:8000", + TPOT_PAYLOAD_SERVER_API_KEY="test-key", + MAX_QUARANTINE_SIZE_GB=5, + ) + @patch("greedybear.cronjobs.payload_extraction.PayloadExtractionJob._quarantine_usage_bytes") + @patch("greedybear.cronjobs.payload_extraction.HttpClient") + def test_download_without_matching_session(self, mock_http_class, mock_usage): + """Job should not link a downloaded payload to any session when no matching transfer exists.""" + mock_client = MagicMock() + mock_http_class.return_value.__enter__ = Mock(return_value=mock_client) + mock_http_class.return_value.__exit__ = Mock(return_value=False) + + mock_metadata_resp = Mock() + mock_metadata_resp.json.return_value = [{"sha256": "a" * 64, "locator": "cowrie/aaa"}] + mock_download_resp = Mock() + mock_download_resp.content = b"\x00" * 256 + mock_client.get.side_effect = [mock_metadata_resp, mock_download_resp] + mock_usage.return_value = 0 + + self.job.run() + + # No CowrieFileTransfer was ever created for this hash, so nothing should link. + obj = HoneypotPayload.objects.get(sha256="a" * 64) + self.assertEqual(obj.cowrie_sessions.count(), 0) + + @override_settings( + TPOT_PAYLOAD_SERVER_URL="http://payload-server:8000", + TPOT_PAYLOAD_SERVER_API_KEY="test-key", + MAX_QUARANTINE_SIZE_GB=5, + ) + @patch("greedybear.cronjobs.payload_extraction.PayloadExtractionJob._quarantine_usage_bytes") + @patch("greedybear.cronjobs.payload_extraction.HttpClient") + def test_download_links_cowrie_session(self, mock_http_class, mock_usage): + """Job should link a downloaded payload to a Cowrie session that already transferred the same file.""" + # Same hash as the payload metadata below - simulates Cowrie's extraction already + # having recorded this transfer before the payload download runs. + CowrieFileTransfer.objects.create( + session=self.cowrie_session, + shasum="a" * 64, + url="", + outfile="", + timestamp=timezone.now(), + ) + + mock_client = MagicMock() + mock_http_class.return_value.__enter__ = Mock(return_value=mock_client) + mock_http_class.return_value.__exit__ = Mock(return_value=False) + + mock_metadata_resp = Mock() + mock_metadata_resp.json.return_value = [{"sha256": "a" * 64, "locator": "cowrie/aaa"}] + mock_download_resp = Mock() + mock_download_resp.content = b"\x00" * 256 + mock_client.get.side_effect = [mock_metadata_resp, mock_download_resp] + mock_usage.return_value = 0 + + self.job.run() + + obj = HoneypotPayload.objects.get(sha256="a" * 64) + self.assertIn(self.cowrie_session, obj.cowrie_sessions.all()) + self.assertIn(self.cowrie_session.source, obj.iocs.all()) + @override_settings( TPOT_PAYLOAD_SERVER_URL="http://payload-server:8000", TPOT_PAYLOAD_SERVER_API_KEY="test-key", diff --git a/tests/test_payload_repository.py b/tests/test_payload_repository.py new file mode 100644 index 000000000..88663082b --- /dev/null +++ b/tests/test_payload_repository.py @@ -0,0 +1,90 @@ +from django.utils import timezone + +from greedybear.cronjobs.repositories import PayloadRepository +from greedybear.models import IOC, CowrieFileTransfer, CowrieSession, HoneypotPayload + +from . import CustomTestCase + + +class TestPayloadRepository(CustomTestCase): + def setUp(self): + super().setUp() + self.repo = PayloadRepository() + + def _make_transfer(self, session, shasum): + return CowrieFileTransfer.objects.create( + session=session, + shasum=shasum, + url="", + outfile="", + timestamp=timezone.now(), + ) + + def test_link_sessions_to_payload_no_match(self): + payload = HoneypotPayload.objects.create(sha256="a" * 64) + linked = self.repo.link_sessions_to_payload(payload) + self.assertEqual(linked, 0) + self.assertEqual(payload.cowrie_sessions.count(), 0) + self.assertEqual(payload.iocs.count(), 0) + + def test_link_sessions_to_payload_single_match(self): + self._make_transfer(self.cowrie_session, "a" * 64) + payload = HoneypotPayload.objects.create(sha256="a" * 64) + + linked = self.repo.link_sessions_to_payload(payload) + + self.assertEqual(linked, 1) + self.assertIn(self.cowrie_session, payload.cowrie_sessions.all()) + self.assertIn(self.cowrie_session.source, payload.iocs.all()) + + def test_link_sessions_to_payload_multiple_sessions(self): + self._make_transfer(self.cowrie_session, "c" * 64) + self._make_transfer(self.cowrie_session_2, "c" * 64) + payload = HoneypotPayload.objects.create(sha256="c" * 64) + + linked = self.repo.link_sessions_to_payload(payload) + + self.assertEqual(linked, 2) + self.assertIn(self.cowrie_session, payload.cowrie_sessions.all()) + self.assertIn(self.cowrie_session_2, payload.cowrie_sessions.all()) + + def test_link_payload_to_session_no_match(self): + linked = self.repo.link_payload_to_session(self.cowrie_session, "e" * 64) + self.assertFalse(linked) + + def test_link_payload_to_session_match(self): + payload = HoneypotPayload.objects.create(sha256="f" * 64) + + linked = self.repo.link_payload_to_session(self.cowrie_session, "f" * 64) + + self.assertTrue(linked) + self.assertIn(self.cowrie_session, payload.cowrie_sessions.all()) + self.assertIn(self.cowrie_session.source, payload.iocs.all()) + + def test_link_payload_to_session_case_insensitive_match(self): + payload = HoneypotPayload.objects.create(sha256=("11" * 32).lower()) + + linked = self.repo.link_payload_to_session(self.cowrie_session, ("11" * 32).upper()) + + self.assertTrue(linked) + self.assertIn(self.cowrie_session, payload.cowrie_sessions.all()) + + def test_link_payload_to_session_idempotent(self): + payload = HoneypotPayload.objects.create(sha256="9" * 64) + + self.repo.link_payload_to_session(self.cowrie_session, "9" * 64) + self.repo.link_payload_to_session(self.cowrie_session, "9" * 64) + + self.assertEqual(payload.cowrie_sessions.count(), 1) + self.assertEqual(payload.iocs.count(), 1) + + def test_link_sessions_to_payload_only_matches_exact_hash(self): + other_ioc = IOC.objects.create(name="203.0.113.9", type="ip") + other_session = CowrieSession.objects.create(session_id=0x7777, source=other_ioc) + self._make_transfer(other_session, "b" * 64) + payload = HoneypotPayload.objects.create(sha256="a" * 64) + + linked = self.repo.link_sessions_to_payload(payload) + + self.assertEqual(linked, 0) + self.assertNotIn(other_session, payload.cowrie_sessions.all()) diff --git a/tests/test_serializers.py b/tests/test_serializers.py index ec6ec97d3..d7fb26ffb 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -6,6 +6,7 @@ from api.serializers import ( AdvancedFeedRequestSerializer, + HoneypotPayloadSerializer, IOCSerializer, SimpleFeedResponseSerializer, TrendingFeedRequestSerializer, @@ -13,7 +14,7 @@ ) from greedybear.consts import PAYLOAD_REQUEST, SCANNER from greedybear.enums import IpReputation -from greedybear.models import IOC, Honeypot, Sensor +from greedybear.models import IOC, Honeypot, HoneypotPayload, Sensor from tests import CustomTestCase @@ -366,3 +367,37 @@ def test_ioc_serializer_with_sensors(self): self.assertEqual(sensors_data[0]["label"], "home-pi") self.assertEqual(sensors_data[1]["address"], "10.0.0.2") self.assertEqual(sensors_data[1]["label"], "") + + +class HoneypotPayloadSerializerTestCase(CustomTestCase): + def test_empty_relations_serialize_as_empty_lists(self): + payload = HoneypotPayload.objects.create(sha256="a" * 64) + + serializer = HoneypotPayloadSerializer(payload) + data = serializer.data + + self.assertEqual(data["iocs"], []) + self.assertEqual(data["cowrie_sessions"], []) + + def test_serializes_iocs_as_names(self): + payload = HoneypotPayload.objects.create(sha256="b" * 64) + payload.iocs.add(self.ioc, self.ioc_2) + + serializer = HoneypotPayloadSerializer(payload) + data = serializer.data + + self.assertEqual(sorted(data["iocs"]), sorted([self.ioc.name, self.ioc_2.name])) + self.assertEqual(data["cowrie_sessions"], []) + + def test_serializes_cowrie_sessions_as_hex_ids(self): + payload = HoneypotPayload.objects.create(sha256="c" * 64) + payload.cowrie_sessions.add(self.cowrie_session, self.cowrie_session_2) + + serializer = HoneypotPayloadSerializer(payload) + data = serializer.data + + self.assertEqual( + sorted(data["cowrie_sessions"]), + sorted([f"{self.cowrie_session.session_id:x}", f"{self.cowrie_session_2.session_id:x}"]), + ) + self.assertEqual(data["iocs"], []) From eb5a219fe0a8870ac5df4cc59807ac47b80e8bed Mon Sep 17 00:00:00 2001 From: Joy Liu <110119346+cclts@users.noreply.github.com> Date: Wed, 9 Sep 2026 01:31:27 -0500 Subject: [PATCH 17/43] Move gb-ui components into frontend. Closes #1454 (#1482) * Move gb-ui components into frontend * Add table visual regression tests * Exclude visual specs from Vitest * Keep visual tests out of CI * Keep visual tests out of DataTable * Remove unused chart wrappers * Document shared UI component source * sync package-lock.json --------- Co-authored-by: tim <46972822+regulartim@users.noreply.github.com> --- frontend/README.md | 7 +- frontend/package-lock.json | 905 ++---------------- frontend/package.json | 9 +- frontend/playwright.config.js | 33 + frontend/src/components/Routes.jsx | 2 +- .../src/components/auth/EmailVerification.jsx | 2 +- frontend/src/components/auth/Login.jsx | 2 +- frontend/src/components/auth/Logout.jsx | 2 +- frontend/src/components/auth/Register.jsx | 2 +- .../src/components/auth/ResetPassword.jsx | 2 +- frontend/src/components/auth/api.js | 2 +- .../auth/utils/registration-buttons.jsx | 2 +- .../gb-ui/components/alerts/ErrorAlert.jsx | 39 + .../buttons/CopyToClipboardButton.jsx | 73 ++ .../gb-ui/components/buttons/IconButton.jsx | 33 + .../components/buttons/PopupFormButton.jsx | 71 ++ .../components/buttons/ScrollToTopButton.jsx | 54 ++ .../components/containers/ContentSection.jsx | 12 + .../gb-ui/components/containers/Loader.jsx | 27 + .../components/containers/LoadingBoundary.jsx | 30 + .../components/containers/SmallInfoCard.jsx | 14 + .../common/gb-ui/components/form/Select.jsx | 20 + .../components/icons/ArrowToggleIcon.jsx | 16 + .../gb-ui/components/icons/BooleanIcon.jsx | 30 + .../gb-ui/components/misc/FallbackLoading.jsx | 13 + .../common/gb-ui/components/misc/Toaster.jsx | 49 + .../gb-ui/components/misc/UserBubble.jsx | 42 + .../gb-ui/components/modals/ConfirmModal.jsx | 69 ++ .../gb-ui/components/nav/DropdownNavLink.jsx | 19 + .../common/gb-ui/components/nav/NavLink.jsx | 37 + .../gb-ui/components/table/DataTable.jsx | 570 +++++++++++ .../gb-ui/components/table/Paginator.jsx | 74 ++ .../common/gb-ui/components/table/filters.jsx | 168 ++++ .../common/gb-ui/components/table/hooks.jsx | 145 +++ .../components/table/queryParamsUtils.jsx | 56 ++ .../gb-ui/components/table/useDataTable.jsx | 89 ++ .../components/table/useQueryParamsTable.jsx | 98 ++ .../common/gb-ui/components/table/utils.jsx | 32 + .../gb-ui/components/time/DateHoverable.jsx | 76 ++ .../components/time/ElasticTimePicker.jsx | 92 ++ .../components/common/gb-ui/hooks/index.js | 2 + .../common/gb-ui/hooks/useAsyncDebounce.jsx | 57 ++ .../gb-ui/hooks/useAxiosComponentLoader.jsx | 88 ++ frontend/src/components/common/gb-ui/index.js | 28 + .../components/common/gb-ui/stores/index.js | 2 + .../gb-ui/stores/useTimePickerStore.jsx | 38 + .../common/gb-ui/stores/useToastr.jsx | 40 + .../common/gb-ui/styles/_alerts.scss | 56 ++ .../common/gb-ui/styles/_button.scss | 59 ++ .../components/common/gb-ui/styles/_card.scss | 32 + .../components/common/gb-ui/styles/_chat.scss | 65 ++ .../components/common/gb-ui/styles/_form.scss | 83 ++ .../components/common/gb-ui/styles/_list.scss | 78 ++ .../components/common/gb-ui/styles/_nav.scss | 76 ++ .../common/gb-ui/styles/_overwrite.scss | 38 + .../common/gb-ui/styles/_recharts.scss | 34 + .../common/gb-ui/styles/_spinners.scss | 28 + .../common/gb-ui/styles/_table.scss | 72 ++ .../components/common/gb-ui/styles/_text.scss | 69 ++ .../common/gb-ui/styles/_toast.scss | 8 + .../common/gb-ui/styles/_utils.scss | 111 +++ .../common/gb-ui/styles/_variables.scss | 278 ++++++ .../components/common/gb-ui/styles/root.scss | 91 ++ .../components/common/gb-ui/utils/index.js | 61 ++ .../src/components/dashboard/Dashboard.jsx | 2 +- .../components/dashboard/EnrichmentLookup.jsx | 2 +- .../components/dashboard/WidgetWrapper.jsx | 2 +- .../src/components/dashboard/utils/charts.jsx | 5 +- frontend/src/components/feeds/Feeds.jsx | 2 +- .../src/components/feeds/FeedsTrending.jsx | 2 +- .../src/components/feeds/tableColumns.jsx | 2 +- frontend/src/components/home/Home.jsx | 2 +- frontend/src/components/home/NewsWidget.jsx | 2 +- .../me/changepassword/ChangePassword.jsx | 2 +- .../src/components/me/sessions/APIaccess.jsx | 2 +- .../components/me/sessions/SessionList.jsx | 2 +- .../src/components/me/sessions/Sessions.jsx | 2 +- frontend/src/components/me/sessions/api.js | 2 +- frontend/src/hooks/useWidgetData.js | 2 +- frontend/src/layouts/AppFooter.jsx | 6 +- frontend/src/layouts/AppHeader.jsx | 2 +- frontend/src/layouts/AppMain.jsx | 2 +- frontend/src/layouts/widget/UserMenu.jsx | 5 +- frontend/src/stores/useAuthStore.jsx | 2 +- frontend/src/stores/useDashboardStore.js | 2 +- frontend/src/styles/App.scss | 2 +- frontend/src/wrappers/AuthGuard.jsx | 2 +- frontend/tests/components/auth/api.test.js | 4 +- .../components/auth/utils/EmailForm.test.jsx | 2 +- .../AttackOriginCountriesChart.test.jsx | 2 +- .../dashboard/AttackOriginMap.test.jsx | 2 +- .../dashboard/ConfigEditor.test.jsx | 33 +- .../components/dashboard/charts.test.jsx | 2 +- .../tests/components/feeds/Feeds.test.jsx | 124 +-- .../components/feeds/FeedsTrending.test.jsx | 8 +- .../me/changepassword/ChangePassword.test.jsx | 6 +- .../components/me/sessions/APIaccess.test.jsx | 11 +- .../me/sessions/SessionList.test.jsx | 11 +- .../components/me/sessions/Sessions.test.jsx | 4 +- .../tests/components/me/sessions/api.test.js | 4 +- frontend/tests/hooks/useWidgetData.test.jsx | 2 +- frontend/tests/stores/useAuthStore.test.jsx | 4 +- .../tests/stores/useDashboardStore.test.js | 2 +- frontend/tests/visual.html | 12 + frontend/tests/visual/TableVisualFixture.jsx | 137 +++ .../tests/visual/fixtures/tableFixtures.js | 53 + frontend/tests/visual/main.jsx | 6 + frontend/tests/visual/table.visual.spec.js | 206 ++++ .../table-default-desktop.png | Bin 0 -> 39601 bytes .../table-default-mobile.png | Bin 0 -> 37881 bytes .../table-disabled-row-desktop.png | Bin 0 -> 32284 bytes .../table-empty-state-desktop.png | Bin 0 -> 12914 bytes .../table-expanded-desktop.png | Bin 0 -> 47510 bytes .../table-filtered-desktop.png | Bin 0 -> 21508 bytes .../table-paginator-ellipsis-desktop.png | Bin 0 -> 2946 bytes .../table-selected-desktop.png | Bin 0 -> 40845 bytes .../table-sorted-asc-desktop.png | Bin 0 -> 39707 bytes .../table-sorted-desc-desktop.png | Bin 0 -> 42548 bytes frontend/tests/wrapper/AuthGuard.test.jsx | 4 +- frontend/vite.config.js | 2 + 120 files changed, 4271 insertions(+), 938 deletions(-) create mode 100644 frontend/playwright.config.js create mode 100644 frontend/src/components/common/gb-ui/components/alerts/ErrorAlert.jsx create mode 100644 frontend/src/components/common/gb-ui/components/buttons/CopyToClipboardButton.jsx create mode 100644 frontend/src/components/common/gb-ui/components/buttons/IconButton.jsx create mode 100644 frontend/src/components/common/gb-ui/components/buttons/PopupFormButton.jsx create mode 100644 frontend/src/components/common/gb-ui/components/buttons/ScrollToTopButton.jsx create mode 100644 frontend/src/components/common/gb-ui/components/containers/ContentSection.jsx create mode 100644 frontend/src/components/common/gb-ui/components/containers/Loader.jsx create mode 100644 frontend/src/components/common/gb-ui/components/containers/LoadingBoundary.jsx create mode 100644 frontend/src/components/common/gb-ui/components/containers/SmallInfoCard.jsx create mode 100644 frontend/src/components/common/gb-ui/components/form/Select.jsx create mode 100644 frontend/src/components/common/gb-ui/components/icons/ArrowToggleIcon.jsx create mode 100644 frontend/src/components/common/gb-ui/components/icons/BooleanIcon.jsx create mode 100644 frontend/src/components/common/gb-ui/components/misc/FallbackLoading.jsx create mode 100644 frontend/src/components/common/gb-ui/components/misc/Toaster.jsx create mode 100644 frontend/src/components/common/gb-ui/components/misc/UserBubble.jsx create mode 100644 frontend/src/components/common/gb-ui/components/modals/ConfirmModal.jsx create mode 100644 frontend/src/components/common/gb-ui/components/nav/DropdownNavLink.jsx create mode 100644 frontend/src/components/common/gb-ui/components/nav/NavLink.jsx create mode 100644 frontend/src/components/common/gb-ui/components/table/DataTable.jsx create mode 100644 frontend/src/components/common/gb-ui/components/table/Paginator.jsx create mode 100644 frontend/src/components/common/gb-ui/components/table/filters.jsx create mode 100644 frontend/src/components/common/gb-ui/components/table/hooks.jsx create mode 100644 frontend/src/components/common/gb-ui/components/table/queryParamsUtils.jsx create mode 100644 frontend/src/components/common/gb-ui/components/table/useDataTable.jsx create mode 100644 frontend/src/components/common/gb-ui/components/table/useQueryParamsTable.jsx create mode 100644 frontend/src/components/common/gb-ui/components/table/utils.jsx create mode 100644 frontend/src/components/common/gb-ui/components/time/DateHoverable.jsx create mode 100644 frontend/src/components/common/gb-ui/components/time/ElasticTimePicker.jsx create mode 100644 frontend/src/components/common/gb-ui/hooks/index.js create mode 100644 frontend/src/components/common/gb-ui/hooks/useAsyncDebounce.jsx create mode 100644 frontend/src/components/common/gb-ui/hooks/useAxiosComponentLoader.jsx create mode 100644 frontend/src/components/common/gb-ui/index.js create mode 100644 frontend/src/components/common/gb-ui/stores/index.js create mode 100644 frontend/src/components/common/gb-ui/stores/useTimePickerStore.jsx create mode 100644 frontend/src/components/common/gb-ui/stores/useToastr.jsx create mode 100644 frontend/src/components/common/gb-ui/styles/_alerts.scss create mode 100644 frontend/src/components/common/gb-ui/styles/_button.scss create mode 100644 frontend/src/components/common/gb-ui/styles/_card.scss create mode 100644 frontend/src/components/common/gb-ui/styles/_chat.scss create mode 100644 frontend/src/components/common/gb-ui/styles/_form.scss create mode 100644 frontend/src/components/common/gb-ui/styles/_list.scss create mode 100644 frontend/src/components/common/gb-ui/styles/_nav.scss create mode 100644 frontend/src/components/common/gb-ui/styles/_overwrite.scss create mode 100644 frontend/src/components/common/gb-ui/styles/_recharts.scss create mode 100644 frontend/src/components/common/gb-ui/styles/_spinners.scss create mode 100644 frontend/src/components/common/gb-ui/styles/_table.scss create mode 100644 frontend/src/components/common/gb-ui/styles/_text.scss create mode 100644 frontend/src/components/common/gb-ui/styles/_toast.scss create mode 100644 frontend/src/components/common/gb-ui/styles/_utils.scss create mode 100644 frontend/src/components/common/gb-ui/styles/_variables.scss create mode 100644 frontend/src/components/common/gb-ui/styles/root.scss create mode 100644 frontend/src/components/common/gb-ui/utils/index.js create mode 100644 frontend/tests/visual.html create mode 100644 frontend/tests/visual/TableVisualFixture.jsx create mode 100644 frontend/tests/visual/fixtures/tableFixtures.js create mode 100644 frontend/tests/visual/main.jsx create mode 100644 frontend/tests/visual/table.visual.spec.js create mode 100644 frontend/tests/visual/table.visual.spec.js-snapshots/table-default-desktop.png create mode 100644 frontend/tests/visual/table.visual.spec.js-snapshots/table-default-mobile.png create mode 100644 frontend/tests/visual/table.visual.spec.js-snapshots/table-disabled-row-desktop.png create mode 100644 frontend/tests/visual/table.visual.spec.js-snapshots/table-empty-state-desktop.png create mode 100644 frontend/tests/visual/table.visual.spec.js-snapshots/table-expanded-desktop.png create mode 100644 frontend/tests/visual/table.visual.spec.js-snapshots/table-filtered-desktop.png create mode 100644 frontend/tests/visual/table.visual.spec.js-snapshots/table-paginator-ellipsis-desktop.png create mode 100644 frontend/tests/visual/table.visual.spec.js-snapshots/table-selected-desktop.png create mode 100644 frontend/tests/visual/table.visual.spec.js-snapshots/table-sorted-asc-desktop.png create mode 100644 frontend/tests/visual/table.visual.spec.js-snapshots/table-sorted-desc-desktop.png diff --git a/frontend/README.md b/frontend/README.md index b97c7e21e..f56e29699 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,6 +1,10 @@ # GreedyBear - frontend -Built with [gb-ui](https://github.com/GreedyBear-Project/gb-ui). +## Shared UI components + +Shared UI components in `src/components/common/gb-ui/` were originally derived +from [certego-ui](https://github.com/certego/certego-ui) and are now maintained +locally in GreedyBear. ## Design thesis @@ -59,4 +63,3 @@ npm start - JS app main configs are available in `package.json` and `enviroments.js`. - diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4e3bf039f..c545f3768 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,13 +8,16 @@ "name": "frontend", "version": "0.1.0", "dependencies": { - "@greedybear/gb-ui": "github:GreedyBear-Project/gb-ui#1.0.1", + "@tanstack/react-table": "^8.21.3", "@vnedyalk0v/react19-simple-maps": "^2.0.10", "axios": "^1.20.0", "axios-hooks": "^5.1.1", "bootstrap": ">=5.3.8", + "classnames": "^2.5.1", + "date-fns": "^2.30.0", "formik": "^2.4.9", "i18n-iso-countries": "^7.14.0", + "nanoid": "^3.3.11", "prop-types": "^15.8.1", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -28,6 +31,7 @@ "zustand": "^5.0.15" }, "devDependencies": { + "@playwright/test": "^1.62.1", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", @@ -85,6 +89,7 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", @@ -95,48 +100,11 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -146,6 +114,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -155,6 +124,7 @@ "version": "7.29.3", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.0" @@ -175,42 +145,11 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/types": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -514,126 +453,6 @@ "postcss-selector-parser": "^7.1.1" } }, - "node_modules/@emotion/babel-plugin": { - "version": "11.13.5", - "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", - "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.16.7", - "@babel/runtime": "^7.18.3", - "@emotion/hash": "^0.9.2", - "@emotion/memoize": "^0.9.0", - "@emotion/serialize": "^1.3.3", - "babel-plugin-macros": "^3.1.0", - "convert-source-map": "^1.5.0", - "escape-string-regexp": "^4.0.0", - "find-root": "^1.1.0", - "source-map": "^0.5.7", - "stylis": "4.2.0" - } - }, - "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "license": "MIT" - }, - "node_modules/@emotion/cache": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", - "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", - "license": "MIT", - "dependencies": { - "@emotion/memoize": "^0.9.0", - "@emotion/sheet": "^1.4.0", - "@emotion/utils": "^1.4.2", - "@emotion/weak-memoize": "^0.4.0", - "stylis": "4.2.0" - } - }, - "node_modules/@emotion/hash": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", - "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", - "license": "MIT" - }, - "node_modules/@emotion/memoize": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", - "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", - "license": "MIT" - }, - "node_modules/@emotion/react": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", - "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.13.5", - "@emotion/cache": "^11.14.0", - "@emotion/serialize": "^1.3.3", - "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", - "@emotion/utils": "^1.4.2", - "@emotion/weak-memoize": "^0.4.0", - "hoist-non-react-statics": "^3.3.1" - }, - "peerDependencies": { - "react": ">=16.8.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@emotion/serialize": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", - "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", - "license": "MIT", - "dependencies": { - "@emotion/hash": "^0.9.2", - "@emotion/memoize": "^0.9.0", - "@emotion/unitless": "^0.10.0", - "@emotion/utils": "^1.4.2", - "csstype": "^3.0.2" - } - }, - "node_modules/@emotion/sheet": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", - "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", - "license": "MIT" - }, - "node_modules/@emotion/unitless": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", - "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", - "license": "MIT" - }, - "node_modules/@emotion/use-insertion-effect-with-fallbacks": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", - "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", - "license": "MIT", - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@emotion/utils": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", - "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", - "license": "MIT" - }, - "node_modules/@emotion/weak-memoize": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", - "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", - "license": "MIT" - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -796,85 +615,6 @@ } } }, - "node_modules/@floating-ui/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", - "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.11" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", - "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", - "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", - "license": "MIT" - }, - "node_modules/@greedybear/gb-ui": { - "version": "1.0.1", - "resolved": "git+ssh://git@github.com/GreedyBear-Project/gb-ui.git#72f95eee3dc48e4353933ce747d2e6488bcc363c", - "license": "MIT", - "dependencies": { - "@microlink/react-json-view": "^1.31.22", - "@tanstack/react-table": "^8.21.3", - "classnames": "^2.5.1", - "date-fns": "^2.30.0", - "match-sorter": "^6.4.0", - "nanoid": "^6.0.0", - "react-compound-slider": "^3.4.0", - "react-icons": "^5.7.0", - "react-infinite-scroll-component": "^7.2.1", - "react-json-editor-ajrm": "^2.5.14", - "react-select": "^5.10.2", - "react-share": "^5.3.0", - "react-top-loading-bar": "^3.0.2", - "react-use": "^17.6.1", - "recharts": "^3.9.2", - "zustand": "^5.0.14" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "axios": "^1.6.0", - "axios-hooks": "^5.1.1", - "bootstrap": "^5.2.1", - "formik": "^2.2.9", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "react-router-dom": "^6.3.0 || ^7.0.0", - "reactstrap": "^9.0.3" - } - }, - "node_modules/@greedybear/gb-ui/node_modules/nanoid": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-6.0.0.tgz", - "integrity": "sha512-mkUH+rPkwU2qPadJ0oJZOjeZ5Mxn8Q1UhevwkTRWNuUZzyia3h4rhzK39hxaHTk0o2OxB8W2SQ6A8k23ZDi1pQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.js" - }, - "engines": { - "node": "^22 || ^24 || >=26" - } - }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -941,20 +681,11 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -970,6 +701,7 @@ "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -983,24 +715,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@microlink/react-json-view": { - "version": "1.31.22", - "resolved": "https://registry.npmjs.org/@microlink/react-json-view/-/react-json-view-1.31.22.tgz", - "integrity": "sha512-cfszidqClRqjnF5YQ5PSGeORo9MnMZvDv+csgq92G1LRZduWEBjeHXKLnulvhE7bX9+ORvjRCWcMQfwRlhPxmA==", - "license": "MIT", - "dependencies": { - "react-base16-styling": "~0.10.0", - "react-lifecycles-compat": "~3.0.4", - "react-textarea-autosize": "~8.5.9" - }, - "engines": { - "node": ">=17" - }, - "peerDependencies": { - "react": ">= 15", - "react-dom": ">= 15" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1044,7 +758,6 @@ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", "dev": true, - "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" } @@ -1172,6 +885,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1192,6 +908,9 @@ "cpu": [ "arm" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1252,6 +971,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1272,6 +994,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1345,6 +1070,21 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -1406,7 +1146,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" @@ -1423,7 +1162,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -1440,7 +1178,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -1457,7 +1194,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "freebsd" @@ -1474,7 +1210,6 @@ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -1491,10 +1226,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -1511,10 +1242,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -1531,10 +1258,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -1551,10 +1274,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -1571,10 +1290,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -1591,10 +1306,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -1611,7 +1322,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "openharmony" @@ -1628,7 +1338,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -1645,7 +1354,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -1809,7 +1517,6 @@ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", "dev": true, - "license": "MIT", "engines": { "node": ">=12", "npm": ">=6" @@ -1939,18 +1646,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/lodash": { - "version": "4.17.24", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", - "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", - "license": "MIT" - }, - "node_modules/@types/parse-json": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", - "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", - "license": "MIT" - }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -1961,15 +1656,6 @@ "csstype": "^3.2.2" } }, - "node_modules/@types/react-transition-group": { - "version": "4.4.12", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", - "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*" - } - }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -1981,7 +1667,6 @@ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", "dev": true, - "license": "MIT", "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, @@ -2154,7 +1839,6 @@ "version": "2.0.10", "resolved": "https://registry.npmjs.org/@vnedyalk0v/react19-simple-maps/-/react19-simple-maps-2.0.10.tgz", "integrity": "sha512-tw8PYah/5c1UiwqhtB0k40p+jlYRW9QjdZZQ37CKONXErIZFHl0Sn8Ho03HNP/mXQs0YrogrBIeolln5mfbIsA==", - "license": "MIT", "dependencies": { "d3-color": "^3.1.0", "d3-geo": "^3.1.0", @@ -2357,42 +2041,6 @@ "node": ">=6.9.0" } }, - "node_modules/babel-plugin-macros": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", - "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5", - "cosmiconfig": "^7.0.0", - "resolve": "^1.19.0" - }, - "engines": { - "node": ">=10", - "npm": ">=6" - } - }, - "node_modules/babel-plugin-macros/node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2430,9 +2078,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -2494,6 +2142,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2556,23 +2205,11 @@ "node": ">=6" } }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2585,18 +2222,9 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "license": "MIT", - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, "node_modules/colord": { "version": "2.9.3", "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", @@ -2640,7 +2268,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "license": "MIT", "engines": { "node": ">=18" }, @@ -2658,31 +2285,6 @@ "toggle-selection": "^1.0.6" } }, - "node_modules/cosmiconfig": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", - "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", - "license": "MIT", - "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/cosmiconfig/node_modules/yaml": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", - "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3125,6 +2727,7 @@ "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -3205,6 +2808,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -3455,9 +3059,9 @@ "integrity": "sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw==" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "dev": true, "funding": [ { @@ -3541,12 +3145,6 @@ "node": ">=8" } }, - "node_modules/find-root": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", - "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", - "license": "MIT" - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -3652,7 +3250,6 @@ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -4015,6 +3612,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -4087,23 +3685,9 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, "license": "MIT" }, - "node_modules/is-core-module": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", - "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", - "license": "MIT", - "dependencies": { - "hasown": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4304,18 +3888,6 @@ "node": "^22.14.0 || >=24.0.0" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -4327,6 +3899,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { @@ -4343,29 +3916,6 @@ "dev": true, "license": "MIT" }, - "node_modules/jsonp": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/jsonp/-/jsonp-0.2.1.tgz", - "integrity": "sha512-pfog5gdDxPdV4eP7Kg87M8/bHgshlZ5pybl+yKxAnCZ5O7lCIn7Ixydj03wOlnDQesky2BPyA91SQ+5Y/mNwzw==", - "dependencies": { - "debug": "^2.1.3" - } - }, - "node_modules/jsonp/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/jsonp/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4405,7 +3955,6 @@ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, - "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" }, @@ -4438,7 +3987,6 @@ "arm64" ], "dev": true, - "license": "MPL-2.0", "optional": true, "os": [ "android" @@ -4459,7 +4007,6 @@ "arm64" ], "dev": true, - "license": "MPL-2.0", "optional": true, "os": [ "darwin" @@ -4480,7 +4027,6 @@ "x64" ], "dev": true, - "license": "MPL-2.0", "optional": true, "os": [ "darwin" @@ -4501,7 +4047,6 @@ "x64" ], "dev": true, - "license": "MPL-2.0", "optional": true, "os": [ "freebsd" @@ -4522,7 +4067,6 @@ "arm" ], "dev": true, - "license": "MPL-2.0", "optional": true, "os": [ "linux" @@ -4543,10 +4087,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", "optional": true, "os": [ "linux" @@ -4567,10 +4107,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", "optional": true, "os": [ "linux" @@ -4591,10 +4127,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", "optional": true, "os": [ "linux" @@ -4615,10 +4147,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", "optional": true, "os": [ "linux" @@ -4639,7 +4167,6 @@ "arm64" ], "dev": true, - "license": "MPL-2.0", "optional": true, "os": [ "win32" @@ -4660,7 +4187,6 @@ "x64" ], "dev": true, - "license": "MPL-2.0", "optional": true, "os": [ "win32" @@ -4677,6 +4203,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, "license": "MIT" }, "node_modules/locate-path": { @@ -4804,17 +4331,6 @@ "node": ">=10" } }, - "node_modules/match-sorter": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.4.0.tgz", - "integrity": "sha512-d4664ahzdL1QTTvmK1iI0JsrxWeJ6gn33qkYtnPg3mcn+naBLtXSgSPOe+X2vUgtgGwaAk3eiaj7gwKjjMAq+Q==", - "deprecated": "This was arguably a breaking change. Not in API, but more results can be returned. Upgrade to the next major when you are ready for that", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.23.8", - "remove-accents": "0.5.0" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4842,12 +4358,6 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/memoize-one": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", - "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", - "license": "MIT" - }, "node_modules/meow": { "version": "14.1.0", "resolved": "https://registry.npmjs.org/meow/-/meow-14.1.0.tgz", @@ -5006,14 +4516,12 @@ "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", - "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -5119,6 +4627,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -5131,6 +4640,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -5178,21 +4688,6 @@ "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "license": "MIT" - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -5204,6 +4699,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -5219,6 +4715,50 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", @@ -5238,7 +4778,6 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", @@ -5441,57 +4980,14 @@ "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", - "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/react-base16-styling": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/react-base16-styling/-/react-base16-styling-0.10.0.tgz", - "integrity": "sha512-H1k2eFB6M45OaiRru3PBXkuCcn2qNmx+gzLb4a9IPMR7tMH8oBRXU5jGbPDYG1Hz+82d88ED0vjR8BmqU3pQdg==", - "license": "MIT", - "dependencies": { - "@types/lodash": "^4.17.0", - "color": "^4.2.3", - "csstype": "^3.1.3", - "lodash-es": "^4.17.21" - } - }, - "node_modules/react-compound-slider": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/react-compound-slider/-/react-compound-slider-3.4.0.tgz", - "integrity": "sha512-KSje/rB0xSvvcb7YV0+82hkiXTV5ljSS7axKrNiXLf9AEO+rrr1Xq4MJWA+6v030YNNo/RoSoEB6D6fnoy+8ng==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5", - "d3-array": "^2.8.0", - "warning": "^4.0.3" - }, - "peerDependencies": { - "react": ">=16.9" - } - }, - "node_modules/react-compound-slider/node_modules/d3-array": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", - "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", - "license": "BSD-3-Clause", - "dependencies": { - "internmap": "^1.0.0" - } - }, - "node_modules/react-compound-slider/node_modules/internmap": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", - "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", - "license": "ISC" - }, "node_modules/react-dom": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", - "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, @@ -5523,7 +5019,6 @@ "version": "2.2.4", "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.4.tgz", "integrity": "sha512-Eb57FsgOMYOfsUGrMI1ku/FFR+dPNPrE8qo+3hwZubpqVSy4GO9v52DeX50Tl3JDYAlCypP4rmw7Vrqk/zOIvA==", - "license": "MIT", "dependencies": { "clsx": "^2.1.1", "fast-equals": "^4.0.3", @@ -5546,19 +5041,6 @@ "react": "*" } }, - "node_modules/react-infinite-scroll-component": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/react-infinite-scroll-component/-/react-infinite-scroll-component-7.2.1.tgz", - "integrity": "sha512-yPuEf6VHBJXFt+YXDxjlnAuhorUwiA2cKURx/P6a7WW3k+TvwY/mbx7KP1rQ1hxGzDcwreczRyLIv6eSd1sFIw==", - "license": "MIT", - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, "node_modules/react-is": { "version": "19.2.6", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", @@ -5566,25 +5048,6 @@ "license": "MIT", "peer": true }, - "node_modules/react-json-editor-ajrm": { - "version": "2.5.14", - "resolved": "https://registry.npmjs.org/react-json-editor-ajrm/-/react-json-editor-ajrm-2.5.14.tgz", - "integrity": "sha512-z2035l5lnU0Wo73AUonj68d1Eh9vjVcLXEa8YogDEeFPcogzYkfT6R38fvYlLKDhP0oI+LyOO/IBWsFkXs4M0Q==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.0.0-rc.0" - }, - "peerDependencies": { - "react": ">=16.2.0", - "react-dom": ">=16.2.0" - } - }, - "node_modules/react-lifecycles-compat": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", - "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", - "license": "MIT" - }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", @@ -5660,69 +5123,6 @@ "react-dom": ">=18" } }, - "node_modules/react-select": { - "version": "5.10.2", - "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz", - "integrity": "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.0", - "@emotion/cache": "^11.4.0", - "@emotion/react": "^11.8.1", - "@floating-ui/dom": "^1.0.1", - "@types/react-transition-group": "^4.4.0", - "memoize-one": "^6.0.0", - "prop-types": "^15.6.0", - "react-transition-group": "^4.3.0", - "use-isomorphic-layout-effect": "^1.2.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/react-share": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/react-share/-/react-share-5.3.0.tgz", - "integrity": "sha512-NU4TqizVi2TuyLpN93/ng2ux+uIGHK5b2dT15cHvZDwfi5b5NuzGych4htCGObN8lubygeamhRxUot4lZ0Pv6Q==", - "license": "MIT", - "dependencies": { - "classnames": "^2.3.2", - "jsonp": "^0.2.1" - }, - "peerDependencies": { - "react": "^17 || ^18 || ^19" - } - }, - "node_modules/react-textarea-autosize": { - "version": "8.5.9", - "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz", - "integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.20.13", - "use-composed-ref": "^1.3.0", - "use-latest": "^1.2.1" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/react-top-loading-bar": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/react-top-loading-bar/-/react-top-loading-bar-3.0.2.tgz", - "integrity": "sha512-hW0CHrHqKdBOBsVhms73ka0rgb9/aoiRfqo7jiS1vwIYDK7VkyMj52ypo5ewhshTQGHKE6mRvR99GZBZ+FuM/Q==", - "license": "MIT", - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "react": "^16 || ^17 || ^18 || ^19" - } - }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -5839,10 +5239,6 @@ "version": "3.10.1", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", - "license": "MIT", - "workspaces": [ - "www" - ], "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", @@ -5900,12 +5296,6 @@ "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", "license": "MIT" }, - "node_modules/remove-accents": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz", - "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==", - "license": "MIT" - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -5932,6 +5322,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -5953,7 +5344,6 @@ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", "dev": true, - "license": "MIT", "dependencies": { "@oxc-project/types": "=0.147.0", "@rolldown/pluginutils": "^1.0.0" @@ -6069,8 +5459,7 @@ "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "license": "MIT" + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" }, "node_modules/set-harmonic-interval": { "version": "1.0.1", @@ -6124,21 +5513,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simple-swizzle": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", - "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, - "node_modules/simple-swizzle/node_modules/is-arrayish": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", - "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", - "license": "MIT" - }, "node_modules/slash": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", @@ -6170,15 +5544,6 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6438,12 +5803,6 @@ "node": ">= 4" } }, - "node_modules/stylis": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", - "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", - "license": "MIT" - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -6500,18 +5859,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/svg-tags": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz", @@ -6794,51 +6141,6 @@ "punycode": "^2.1.0" } }, - "node_modules/use-composed-ref": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz", - "integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-isomorphic-layout-effect": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", - "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-latest": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz", - "integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==", - "license": "MIT", - "dependencies": { - "use-isomorphic-layout-effect": "^1.1.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -6882,7 +6184,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "dev": true, - "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", diff --git a/frontend/package.json b/frontend/package.json index b5e464c92..7068e1553 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,13 +9,16 @@ "prettier": "../.github/configurations/node_linters/prettier/.prettierrc.js" }, "dependencies": { - "@greedybear/gb-ui": "github:GreedyBear-Project/gb-ui#1.0.1", + "@tanstack/react-table": "^8.21.3", "@vnedyalk0v/react19-simple-maps": "^2.0.10", "axios": "^1.20.0", "axios-hooks": "^5.1.1", "bootstrap": ">=5.3.8", + "classnames": "^2.5.1", + "date-fns": "^2.30.0", "formik": "^2.4.9", "i18n-iso-countries": "^7.14.0", + "nanoid": "^3.3.11", "prop-types": "^15.8.1", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -34,6 +37,9 @@ "preview": "vite preview", "test": "vitest", "test-coverage": "vitest run --coverage", + "start:visual": "vite --host 127.0.0.1 --port 3002", + "test:visual": "playwright test", + "test:visual:update": "playwright test --update-snapshots", "lint-config-install": "cd ../.github/configurations/node_linters/eslint && npm i", "lint": "eslint 'src/**/*.{js,jsx}' 'tests/**/*.{js,jsx}'", "lint-fix": "npm run lint -- --fix", @@ -47,6 +53,7 @@ "d3-color": "^3.1.0" }, "devDependencies": { + "@playwright/test": "^1.62.1", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", diff --git a/frontend/playwright.config.js b/frontend/playwright.config.js new file mode 100644 index 000000000..a440a4e28 --- /dev/null +++ b/frontend/playwright.config.js @@ -0,0 +1,33 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/visual", + fullyParallel: true, + reporter: "html", + snapshotPathTemplate: "{testDir}/{testFilePath}-snapshots/{arg}{ext}", + use: { + baseURL: "http://127.0.0.1:3002", + trace: "on-first-retry", + }, + projects: [ + { + name: "desktop-chromium", + use: { + ...devices["Desktop Chrome"], + viewport: { width: 1440, height: 1200 }, + }, + }, + { + name: "mobile-chromium", + use: { + ...devices["Pixel 7"], + }, + }, + ], + webServer: { + command: "npm run start:visual", + url: "http://127.0.0.1:3002/tests/visual.html", + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, +}); diff --git a/frontend/src/components/Routes.jsx b/frontend/src/components/Routes.jsx index 95fa3786e..820d1c58b 100644 --- a/frontend/src/components/Routes.jsx +++ b/frontend/src/components/Routes.jsx @@ -1,5 +1,5 @@ import React, { Suspense } from "react"; -import { FallBackLoading } from "@greedybear/gb-ui"; +import { FallBackLoading } from "./common/gb-ui/index"; import IfAuthRedirectGuard from "../wrappers/ifAuthRedirectGuard"; import AuthGuard from "../wrappers/AuthGuard"; diff --git a/frontend/src/components/auth/EmailVerification.jsx b/frontend/src/components/auth/EmailVerification.jsx index 371c659b4..b339ed203 100644 --- a/frontend/src/components/auth/EmailVerification.jsx +++ b/frontend/src/components/auth/EmailVerification.jsx @@ -1,7 +1,7 @@ import React from "react"; import { Navigate, useSearchParams } from "react-router-dom"; import { Spinner } from "reactstrap"; -import { ContentSection } from "@greedybear/gb-ui"; +import { ContentSection } from "../common/gb-ui/index"; import { verifyEmail } from "./api"; diff --git a/frontend/src/components/auth/Login.jsx b/frontend/src/components/auth/Login.jsx index ba488f4ca..bf4253a2b 100644 --- a/frontend/src/components/auth/Login.jsx +++ b/frontend/src/components/auth/Login.jsx @@ -12,7 +12,7 @@ import { import { MdInfoOutline } from "react-icons/md"; import { Form, Formik } from "formik"; -import { ContentSection } from "@greedybear/gb-ui"; +import { ContentSection } from "../common/gb-ui/index"; import { useAuthStore } from "../../stores"; import { diff --git a/frontend/src/components/auth/Logout.jsx b/frontend/src/components/auth/Logout.jsx index a3fd1d2f2..ab3d57a6e 100644 --- a/frontend/src/components/auth/Logout.jsx +++ b/frontend/src/components/auth/Logout.jsx @@ -1,7 +1,7 @@ import React from "react"; import { useShallow } from "zustand/shallow"; -import { FallBackLoading } from "@greedybear/gb-ui"; +import { FallBackLoading } from "../common/gb-ui/index"; import { useAuthStore } from "../../stores"; import { AUTHENTICATION_STATUSES } from "../../constants"; diff --git a/frontend/src/components/auth/Register.jsx b/frontend/src/components/auth/Register.jsx index 62265ae32..fc37a60cf 100644 --- a/frontend/src/components/auth/Register.jsx +++ b/frontend/src/components/auth/Register.jsx @@ -14,7 +14,7 @@ import { import { Form, Formik } from "formik"; import { useTitle } from "react-use"; -import { ContentSection, Select } from "@greedybear/gb-ui"; +import { ContentSection, Select } from "../common/gb-ui/index"; import { AfterRegistrationModalAlert, diff --git a/frontend/src/components/auth/ResetPassword.jsx b/frontend/src/components/auth/ResetPassword.jsx index 7f319bcd2..cdce34664 100644 --- a/frontend/src/components/auth/ResetPassword.jsx +++ b/frontend/src/components/auth/ResetPassword.jsx @@ -4,7 +4,7 @@ import { FormGroup, Label, Col, Input, Button, Spinner } from "reactstrap"; import { Form, Formik } from "formik"; import { useTitle } from "react-use"; -import { ContentSection } from "@greedybear/gb-ui"; +import { ContentSection } from "../common/gb-ui/index"; import { UUID_REGEX } from "../../constants/index"; import { resetPassword } from "./api"; diff --git a/frontend/src/components/auth/api.js b/frontend/src/components/auth/api.js index 9f732943d..8fe4f72a0 100644 --- a/frontend/src/components/auth/api.js +++ b/frontend/src/components/auth/api.js @@ -1,6 +1,6 @@ import axios from "axios"; -import { addToast } from "@greedybear/gb-ui"; +import { addToast } from "../common/gb-ui/index"; import { AUTH_BASE_URI } from "../../constants/api"; diff --git a/frontend/src/components/auth/utils/registration-buttons.jsx b/frontend/src/components/auth/utils/registration-buttons.jsx index fb67d4084..bc52c6a58 100644 --- a/frontend/src/components/auth/utils/registration-buttons.jsx +++ b/frontend/src/components/auth/utils/registration-buttons.jsx @@ -2,7 +2,7 @@ import React from "react"; // import PropTypes from "prop-types"; import { Alert, PopoverBody } from "reactstrap"; import { IoMail } from "react-icons/io5"; -import { PopupFormButton } from "@greedybear/gb-ui"; +import { PopupFormButton } from "../../common/gb-ui/index"; import EmailForm from "./EmailForm"; import { resendVerificationMail, requestPasswordReset } from "../api"; diff --git a/frontend/src/components/common/gb-ui/components/alerts/ErrorAlert.jsx b/frontend/src/components/common/gb-ui/components/alerts/ErrorAlert.jsx new file mode 100644 index 000000000..aa1d0e643 --- /dev/null +++ b/frontend/src/components/common/gb-ui/components/alerts/ErrorAlert.jsx @@ -0,0 +1,39 @@ +import React from "react"; +import classnames from "classnames"; +import { Alert, Col } from "reactstrap"; +import { MdError } from "react-icons/md"; + +const sizeToWidth = { + sm: 4, + md: 8, + lg: 10, + xl: 12, +}; + +function ErrorAlert({ error, size = "md", className = undefined, ...rest }) { + return ( + +

+ +   Error! +

+
+
+ {[error?.response?.status, error?.response?.statusText] + .filter(Boolean) + .join(" | ")} +
+
+ {error?.parsedMsg?.toString()} +
+
+ ); +} + +export default ErrorAlert; diff --git a/frontend/src/components/common/gb-ui/components/buttons/CopyToClipboardButton.jsx b/frontend/src/components/common/gb-ui/components/buttons/CopyToClipboardButton.jsx new file mode 100644 index 000000000..07a867956 --- /dev/null +++ b/frontend/src/components/common/gb-ui/components/buttons/CopyToClipboardButton.jsx @@ -0,0 +1,73 @@ +import React from "react"; +import { Tooltip, UncontrolledTooltip } from "reactstrap"; +import { HiClipboardCopy } from "react-icons/hi"; + +import useCopyToClipboard from "react-use/lib/useCopyToClipboard"; + +function CopyToClipboardButton({ + id, + text, + children = null, + tooltip = null, + showOnHover = false, + ...rest +}) { + // props + + // local state + const [copied, setCopied] = React.useState(false); + const timeoutRef = React.useRef(null); + + const [, copyToClipboard] = useCopyToClipboard(); + + React.useEffect( + () => () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }, + [], + ); + + // callbacks + const onCopy = () => { + copyToClipboard(text); + setCopied(true); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout( + () => setCopied(false), + tooltip?.timeout || 1000, + ); + }; + + return ( + <> + + {children || } + + + {tooltip?.text || "Copied!"} + + {showOnHover && ( + + Click to copy + + )} + + ); +} + +export default CopyToClipboardButton; diff --git a/frontend/src/components/common/gb-ui/components/buttons/IconButton.jsx b/frontend/src/components/common/gb-ui/components/buttons/IconButton.jsx new file mode 100644 index 000000000..2ed4b827e --- /dev/null +++ b/frontend/src/components/common/gb-ui/components/buttons/IconButton.jsx @@ -0,0 +1,33 @@ +import React from "react"; +import { Button, UncontrolledTooltip } from "reactstrap"; +import { nanoid } from "nanoid"; + +function IconButton({ + id = undefined, + title = undefined, + titlePlacement = "right-start", + Icon, + ...rest +}) { + // props + + // vars + const btnId = React.useMemo(() => id || `iconbtn-${nanoid(4)}`, [id]); + + return ( + + ); +} + +export default IconButton; diff --git a/frontend/src/components/common/gb-ui/components/buttons/PopupFormButton.jsx b/frontend/src/components/common/gb-ui/components/buttons/PopupFormButton.jsx new file mode 100644 index 000000000..4c69694b0 --- /dev/null +++ b/frontend/src/components/common/gb-ui/components/buttons/PopupFormButton.jsx @@ -0,0 +1,71 @@ +import React from "react"; +import { Button, Popover, UncontrolledTooltip, PopoverBody } from "reactstrap"; +import { nanoid } from "nanoid"; +import { IoMdClose } from "react-icons/io"; + +export default function PopupFormButton({ + id = undefined, + title = null, + titlePlacement = "right-start", + popOverPlacement = "right-start", + Icon, + Form, + onFormSuccess = () => null, + ...rest +}) { + // props + + // state + const [popoverOpen, setPopoverOpen] = React.useState(false); + const closeTimeoutRef = React.useRef(null); + + React.useEffect( + () => () => { + if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current); + }, + [], + ); + + // callbacks + const onFormSubmit = React.useCallback(() => { + if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current); + closeTimeoutRef.current = setTimeout(() => setPopoverOpen(false), 400); + onFormSuccess(); + }, [onFormSuccess]); + + // vars + const btnId = React.useMemo(() => id || `popover-btn-${nanoid(4)}`, [id]); + + return ( + <> + + {title && ( + + {title} + + )} + setPopoverOpen(!popoverOpen)} + > + +
+ + + + ); +} diff --git a/frontend/src/components/common/gb-ui/components/buttons/ScrollToTopButton.jsx b/frontend/src/components/common/gb-ui/components/buttons/ScrollToTopButton.jsx new file mode 100644 index 000000000..1c7810cbd --- /dev/null +++ b/frontend/src/components/common/gb-ui/components/buttons/ScrollToTopButton.jsx @@ -0,0 +1,54 @@ +import React from "react"; +import { RiArrowUpSLine } from "react-icons/ri"; +import { Button } from "reactstrap"; + +export default function ScrollToTopButton({ + defaultVisible = false, + scrollYOffset = 300, + ...rest +}) { + // props + + // local state + const [isVisible, setIsVisible] = React.useState(defaultVisible); + + // Show button when page is scrolled upto given distance + const toggleVisibility = React.useCallback( + () => + window.pageYOffset > scrollYOffset + ? setIsVisible(true) + : setIsVisible(false), + [scrollYOffset, setIsVisible], + ); + + // Set the top cordinate to 0 + // make scrolling smooth + const scrollToTop = () => + window.scrollTo({ + top: 0, + behavior: "smooth", + }); + + React.useEffect(() => { + window.addEventListener("scroll", toggleVisibility); + toggleVisibility(); + + return () => { + window.removeEventListener("scroll", toggleVisibility); + }; + }, [toggleVisibility]); + + return ( +
+ {isVisible && ( + + )} +
+ ); +} diff --git a/frontend/src/components/common/gb-ui/components/containers/ContentSection.jsx b/frontend/src/components/common/gb-ui/components/containers/ContentSection.jsx new file mode 100644 index 000000000..0f883dd7f --- /dev/null +++ b/frontend/src/components/common/gb-ui/components/containers/ContentSection.jsx @@ -0,0 +1,12 @@ +import React from "react"; +import classnames from "classnames"; + +function ContentSection({ className = null, children, ...rest }) { + return ( +
+ {children} +
+ ); +} + +export default ContentSection; diff --git a/frontend/src/components/common/gb-ui/components/containers/Loader.jsx b/frontend/src/components/common/gb-ui/components/containers/Loader.jsx new file mode 100644 index 000000000..85af15350 --- /dev/null +++ b/frontend/src/components/common/gb-ui/components/containers/Loader.jsx @@ -0,0 +1,27 @@ +import React from "react"; +import { Spinner } from "reactstrap"; + +import ErrorAlert from "../alerts/ErrorAlert"; + +function Loader({ + loading, + error = null, + render, + renderError = ErrorAlert, + size = "md", +}) { + return ( + <> + {loading && ( + + )} + {error && renderError({ error, size })} + {!(loading || error) && render()} + + ); +} + +export default Loader; diff --git a/frontend/src/components/common/gb-ui/components/containers/LoadingBoundary.jsx b/frontend/src/components/common/gb-ui/components/containers/LoadingBoundary.jsx new file mode 100644 index 000000000..6f187ddac --- /dev/null +++ b/frontend/src/components/common/gb-ui/components/containers/LoadingBoundary.jsx @@ -0,0 +1,30 @@ +import React from "react"; +import { Spinner } from "reactstrap"; + +import ErrorAlert from "../alerts/ErrorAlert"; + +function LoadingBoundary({ + loading = false, + error = null, + size = "md", + render, + renderError = ErrorAlert, +}) { + const hasError = Boolean(error?.response || error?.message); + const RenderError = renderError; + + return ( + <> + {loading && ( + + )} + {hasError && } + {!loading && !hasError && render()} + + ); +} + +export default LoadingBoundary; diff --git a/frontend/src/components/common/gb-ui/components/containers/SmallInfoCard.jsx b/frontend/src/components/common/gb-ui/components/containers/SmallInfoCard.jsx new file mode 100644 index 000000000..85fd5d8d1 --- /dev/null +++ b/frontend/src/components/common/gb-ui/components/containers/SmallInfoCard.jsx @@ -0,0 +1,14 @@ +import React from "react"; +import classNames from "classnames"; +import { Card, CardBody, CardHeader } from "reactstrap"; + +function SmallInfoCard({ header, body, className = undefined, ...rest }) { + return ( + + {header} + {body} + + ); +} + +export default SmallInfoCard; diff --git a/frontend/src/components/common/gb-ui/components/form/Select.jsx b/frontend/src/components/common/gb-ui/components/form/Select.jsx new file mode 100644 index 000000000..a4d710b18 --- /dev/null +++ b/frontend/src/components/common/gb-ui/components/form/Select.jsx @@ -0,0 +1,20 @@ +import React from "react"; +import { Input } from "reactstrap"; + +export default function Select({ + choices, + allowEmpty = false, + emptyLabel = "", + ...rest +}) { + return ( + + {allowEmpty && } + {choices.map(({ label: text, value }) => ( + + ))} + + ); +} diff --git a/frontend/src/components/common/gb-ui/components/icons/ArrowToggleIcon.jsx b/frontend/src/components/common/gb-ui/components/icons/ArrowToggleIcon.jsx new file mode 100644 index 000000000..d59ef5e42 --- /dev/null +++ b/frontend/src/components/common/gb-ui/components/icons/ArrowToggleIcon.jsx @@ -0,0 +1,16 @@ +import React from "react"; + +import { MdExpandLess, MdExpandMore } from "react-icons/md"; +import classNames from "classnames"; + +function ArrowToggleIcon({ isExpanded, className = null, ...rest }) { + const cls = classNames("pointer bg-tertiary rounded-pill", className); + + return isExpanded ? ( + + ) : ( + + ); +} + +export default ArrowToggleIcon; diff --git a/frontend/src/components/common/gb-ui/components/icons/BooleanIcon.jsx b/frontend/src/components/common/gb-ui/components/icons/BooleanIcon.jsx new file mode 100644 index 000000000..39e44e25f --- /dev/null +++ b/frontend/src/components/common/gb-ui/components/icons/BooleanIcon.jsx @@ -0,0 +1,30 @@ +import React from "react"; +import classnames from "classnames"; +import { MdCheck, MdCheckCircleOutline } from "react-icons/md"; +import { IoMdCloseCircleOutline, IoMdClose } from "react-icons/io"; + +export default function BooleanIcon({ + truthy, + withColors = false, + outline = true, + ...rest +}) { + const Check = outline ? MdCheckCircleOutline : MdCheck; + const Close = outline ? IoMdCloseCircleOutline : IoMdClose; + + return truthy ? ( + + ) : ( + + ); +} diff --git a/frontend/src/components/common/gb-ui/components/misc/FallbackLoading.jsx b/frontend/src/components/common/gb-ui/components/misc/FallbackLoading.jsx new file mode 100644 index 000000000..a082b694a --- /dev/null +++ b/frontend/src/components/common/gb-ui/components/misc/FallbackLoading.jsx @@ -0,0 +1,13 @@ +import React from "react"; +import { Spinner } from "reactstrap"; + +export default function FallBackLoading({ text = "Loading...", ...rest }) { + return ( +
+
+ +

{text}

+
+
+ ); +} diff --git a/frontend/src/components/common/gb-ui/components/misc/Toaster.jsx b/frontend/src/components/common/gb-ui/components/misc/Toaster.jsx new file mode 100644 index 000000000..86fd4072c --- /dev/null +++ b/frontend/src/components/common/gb-ui/components/misc/Toaster.jsx @@ -0,0 +1,49 @@ +import React, { useState, useCallback } from "react"; +import { Toast, ToastBody, ToastHeader, Spinner } from "reactstrap"; +import { MdError, MdWarning, MdInfo, MdCheckCircle } from "react-icons/md"; + +function getIcon(color) { + const iconChoices = { + danger: MdError, + warning: MdWarning, + info: MdInfo, + success: MdCheckCircle, + spinner: Spinner, + }; + return iconChoices[color] ? iconChoices[color] : iconChoices.info; +} + +export default function Toaster({ + header, + body = null, + color = "info", + timeout = 4000, + showToggle = false, + ...props +}) { + // state + const [show, setShow] = useState(true); + const toggle = useCallback(() => setShow((v) => !v), [setShow]); + + React.useEffect(() => { + const timer = setTimeout(() => setShow(false), timeout); + return () => clearTimeout(timer); + }, [timeout]); + + const Icon = getIcon(color); + + const toastColor = color === "spinner" ? "info" : color; + + return ( + + + + {header} + + {body && {body}} + + ); +} diff --git a/frontend/src/components/common/gb-ui/components/misc/UserBubble.jsx b/frontend/src/components/common/gb-ui/components/misc/UserBubble.jsx new file mode 100644 index 000000000..14907659f --- /dev/null +++ b/frontend/src/components/common/gb-ui/components/misc/UserBubble.jsx @@ -0,0 +1,42 @@ +import React from "react"; +import classnames from "classnames"; + +/** + * @type {component} + * @param props + */ +export default function UserBubble({ + userInfo, + size = "sm", + className = undefined, + ...elProps +}) { + const userInitials = + userInfo?.first_name?.charAt(0).concat(userInfo?.last_name?.charAt(0)) || + userInfo?.username?.slice(0, 2); + + const bubbleClassName = React.useMemo( + () => + classnames( + "chat-bubble", + { + "chat-bubble-sm": size === "sm", + "chat-bubble-xs": size === "xs", + highlight: true, + }, + className, + ), + [className, size], + ); + + return ( +
+ {userInitials} +
+ ); +} diff --git a/frontend/src/components/common/gb-ui/components/modals/ConfirmModal.jsx b/frontend/src/components/common/gb-ui/components/modals/ConfirmModal.jsx new file mode 100644 index 000000000..29ad0b88c --- /dev/null +++ b/frontend/src/components/common/gb-ui/components/modals/ConfirmModal.jsx @@ -0,0 +1,69 @@ +import React, { Fragment } from "react"; +import { createRoot } from "react-dom/client"; +import { Modal, ModalHeader, ModalBody, ModalFooter, Button } from "reactstrap"; + +function ConfirmModal({ + onClose, + message = "Are you sure?", + title = "Warning!", + confirmText = "Ok", + cancelText = "Cancel", + confirmColor = "primary", + cancelColor = "", + className = "", + buttonsComponent = null, + size = null, + bodyComponent = null, + modalProps = {}, +}) { + let buttonsContent = ( + + {cancelText && ( + + )}{" "} + + + ); + + if (buttonsComponent) { + const CustomComponent = buttonsComponent; + buttonsContent = ; + } + + const BodyComponent = bodyComponent; + + return ( + onClose(false)} + className={`reactstrap-confirm ${className}`} + {...modalProps} + > + {title && ( + onClose(false)}>{title || null} + )} + {bodyComponent ? : message} + {buttonsContent} + + ); +} + +export const confirm = (props) => + new Promise((resolve) => { + const el = document.createElement("div"); + document.body.appendChild(el); + const root = createRoot(el); + const handleResolve = (result) => { + root.unmount(); + el.remove(); + resolve(result); + }; + root.render(); + }); + +export default ConfirmModal; diff --git a/frontend/src/components/common/gb-ui/components/nav/DropdownNavLink.jsx b/frontend/src/components/common/gb-ui/components/nav/DropdownNavLink.jsx new file mode 100644 index 000000000..4a5cf9ff9 --- /dev/null +++ b/frontend/src/components/common/gb-ui/components/nav/DropdownNavLink.jsx @@ -0,0 +1,19 @@ +import React from "react"; +import { NavLink as RRNavLink } from "react-router-dom"; + +/** + * @type {component} + * @param props + */ +export default function DropdownNavLink(props) { + const { children, ...toPassProps } = props; + + return ( + `dropdown-item${isActive ? " active" : ""}`} + {...toPassProps} + > + {children} + + ); +} diff --git a/frontend/src/components/common/gb-ui/components/nav/NavLink.jsx b/frontend/src/components/common/gb-ui/components/nav/NavLink.jsx new file mode 100644 index 000000000..b7f1784de --- /dev/null +++ b/frontend/src/components/common/gb-ui/components/nav/NavLink.jsx @@ -0,0 +1,37 @@ +import React from "react"; +import classnames from "classnames"; +import { NavLink as RRNavLink } from "react-router-dom"; + +// constants +const type2ClassnameMap = { + default: "", + primary: "link-primary", + primaryUl: "link-ul-primary", + muted: "link-muted", + mutedUl: "link-ul-muted", +}; + +// component +export default function NavLink({ + type = "default", + children, + className = null, + ...props +}) { + return ( + + classnames( + "nav-link", + "text-lg-center", + type2ClassnameMap[type], + className, + { active: isActive }, + ) + } + {...props} + > + {children} + + ); +} diff --git a/frontend/src/components/common/gb-ui/components/table/DataTable.jsx b/frontend/src/components/common/gb-ui/components/table/DataTable.jsx new file mode 100644 index 000000000..341899044 --- /dev/null +++ b/frontend/src/components/common/gb-ui/components/table/DataTable.jsx @@ -0,0 +1,570 @@ +/* eslint-disable no-nested-ternary */ +import React from "react"; +import classnames from "classnames"; +import { Table } from "reactstrap"; +import { + useReactTable, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + getPaginationRowModel, +} from "@tanstack/react-table"; +import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa"; + +import { makeTableArgs, defaultConfig, defaultInitialState } from "./utils"; +import Paginator from "./Paginator"; + +function getColumnId(column) { + return column.id || column.accessor; +} + +function getAccessorFn(column) { + if (typeof column.accessor === "function") return column.accessor; + if (typeof column.accessor === "string") + return (row) => row?.[column.accessor]; + return () => undefined; +} + +function normalizeColumn(column, index) { + const id = getColumnId(column) || `column_${index}`; + const accessorFn = getAccessorFn(column); + + return { + ...column, + id, + accessorFn, + }; +} + +function getLegacyRowValue(row, column) { + if (!column?.accessorFn) return undefined; + return column.accessorFn(row); +} + +function legacyDefaultFilterFn(row, columnId, filterValue) { + if (filterValue === undefined || filterValue === null || filterValue === "") + return true; + + const normalizedFilter = `${filterValue}`.toLowerCase(); + const rowValue = row.getValue(columnId); + + if (Array.isArray(rowValue)) { + return rowValue.some((value) => + `${value ?? ""}`.toLowerCase().includes(normalizedFilter), + ); + } + + return `${rowValue ?? ""}`.toLowerCase().includes(normalizedFilter); +} + +/** + * Suitable when data is already available client side. Thus, pagination/filtering/sorting can be performed client side too. + */ +function DataTable({ + config: userConfig = defaultConfig, + onSelectedRowChange = undefined, + SubComponent = undefined, + tableProps = undefined, + tableEmptyNode = "No Data", + TableBodyComponent = undefined, + columns = [], + data = [], + initialState = defaultInitialState, + stateReducer = undefined, + isRowSelectable = () => true, + customProps = undefined, + manualPagination = false, + manualFilters = false, + manualSortBy = false, + pageCount = undefined, +}) { + const config = React.useMemo( + () => ({ ...defaultConfig, ...userConfig }), + [userConfig], + ); + + const tableArgs = React.useMemo(() => makeTableArgs(config), [config]); + + const hookRegistry = React.useMemo(() => { + const registry = { visibleColumns: [], getRowProps: [] }; + tableArgs.forEach((hook) => { + if (typeof hook === "function") hook(registry); + }); + return registry; + }, [tableArgs]); + + const baseColumns = React.useMemo( + () => columns.map((column, index) => normalizeColumn(column, index)), + [columns], + ); + + const baseInstance = React.useMemo( + () => ({ customProps, isRowSelectable }), + [customProps, isRowSelectable], + ); + + const legacyColumns = React.useMemo(() => { + let transformedColumns = baseColumns; + hookRegistry.visibleColumns.forEach((transformer) => { + transformedColumns = transformer(transformedColumns, { + instance: baseInstance, + }); + }); + return transformedColumns; + }, [baseColumns, hookRegistry, baseInstance]); + + const tanstackColumns = React.useMemo( + () => + legacyColumns.map((column) => ({ + id: column.id, + accessorFn: column.accessorFn, + enableSorting: config.enableSortBy && !column.disableSortBy, + enableColumnFilter: config.enableFilters && !!column.Filter, + filterFn: column.filterFn || legacyDefaultFilterFn, + meta: { legacyColumn: column }, + })), + [legacyColumns, config.enableSortBy, config.enableFilters], + ); + + const legacyInitialState = React.useMemo( + () => ({ ...defaultInitialState, ...initialState }), + [initialState], + ); + + const [tableState, setTableState] = React.useState(() => ({ + pagination: { + pageIndex: legacyInitialState.pageIndex || 0, + pageSize: legacyInitialState.pageSize || 10, + }, + sorting: legacyInitialState.sortBy || [], + columnFilters: legacyInitialState.filters || [], + rowSelection: legacyInitialState.selectedRowIds || {}, + expanded: {}, + })); + + const reduceState = React.useCallback( + (nextState, action, prevState) => { + if (!stateReducer) return nextState; + return stateReducer(nextState, action, prevState) || nextState; + }, + [stateReducer], + ); + + const updateState = React.useCallback( + (producer, action) => { + setTableState((previousState) => { + const nextState = producer(previousState); + return reduceState(nextState, action, previousState); + }); + }, + [reduceState], + ); + + const toggleRowSelected = React.useCallback( + (rowId, selected) => { + updateState( + (previousState) => ({ + ...previousState, + rowSelection: { + ...previousState.rowSelection, + [rowId]: selected, + }, + }), + { type: "toggleRowSelected", id: rowId, value: selected }, + ); + }, + [updateState], + ); + + const table = useReactTable({ + data, + columns: tanstackColumns, + state: tableState, + pageCount, + manualPagination, + manualFiltering: manualFilters, + manualSorting: manualSortBy, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + enableRowSelection: (row) => + isRowSelectable({ + id: row.id, + original: row.original, + }), + onPaginationChange: (updater) => { + updateState( + (previousState) => { + const nextPagination = + typeof updater === "function" + ? updater(previousState.pagination) + : updater; + + return { + ...previousState, + pagination: nextPagination, + }; + }, + { + type: + typeof updater === "function" + ? "gotoPage" + : updater.pageIndex !== tableState.pagination.pageIndex + ? "gotoPage" + : "setPageSize", + pageIndex: + typeof updater === "function" + ? updater(tableState.pagination).pageIndex + : updater.pageIndex, + }, + ); + }, + onSortingChange: (updater) => { + updateState( + (previousState) => ({ + ...previousState, + sorting: + typeof updater === "function" + ? updater(previousState.sorting) + : updater, + }), + { type: "toggleSortBy" }, + ); + }, + onColumnFiltersChange: (updater) => { + updateState( + (previousState) => ({ + ...previousState, + columnFilters: + typeof updater === "function" + ? updater(previousState.columnFilters) + : updater, + }), + { type: "setFilter" }, + ); + }, + onRowSelectionChange: (updater) => { + updateState( + (previousState) => ({ + ...previousState, + rowSelection: + typeof updater === "function" + ? updater(previousState.rowSelection) + : updater, + }), + { type: "toggleRowSelected" }, + ); + }, + onExpandedChange: (updater) => { + updateState( + (previousState) => ({ + ...previousState, + expanded: + typeof updater === "function" + ? updater(previousState.expanded) + : updater, + }), + { type: "toggleRowExpanded" }, + ); + }, + }); + + const toLegacyRow = React.useCallback( + (row) => ({ + id: row.id, + original: row.original, + values: Object.fromEntries( + legacyColumns.map((column) => [ + column.id, + getLegacyRowValue(row.original, column), + ]), + ), + isSelected: row.getIsSelected(), + isExpanded: row.getIsExpanded(), + getToggleRowSelectedProps: () => ({ + checked: row.getIsSelected(), + onChange: (event) => row.toggleSelected(event.target.checked), + }), + getToggleRowExpandedProps: () => ({ + role: "button", + tabIndex: 0, + onClick: () => row.toggleExpanded(), + onKeyDown: (event) => { + if (event.key === "Enter" || event.key === " ") row.toggleExpanded(); + }, + }), + }), + [legacyColumns], + ); + + const pageRows = table.getRowModel().rows; + const legacyPageRows = React.useMemo( + () => pageRows.map((row) => toLegacyRow(row)), + [pageRows, toLegacyRow], + ); + + const selectedFlatRows = React.useMemo( + () => table.getSelectedRowModel().flatRows.map((row) => toLegacyRow(row)), + [table, toLegacyRow], + ); + + React.useEffect(() => { + if (!onSelectedRowChange) return; + onSelectedRowChange(selectedFlatRows.map((row) => row.original)); + }, [onSelectedRowChange, selectedFlatRows]); + + const tableInstanceContext = React.useMemo( + () => ({ + ...baseInstance, + page: legacyPageRows, + selectedFlatRows, + toggleRowSelected, + }), + [baseInstance, legacyPageRows, selectedFlatRows, toggleRowSelected], + ); + + const renderHeaderContent = React.useCallback( + (column) => { + const Header = column?.Header; + if (typeof Header === "function") { + return ( +
+ ); + } + return Header; + }, + [selectedFlatRows, toggleRowSelected, legacyPageRows], + ); + + const renderFooterContent = React.useCallback((column) => { + const Footer = column?.Footer; + if (typeof Footer === "function") return