diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 73ebd941..8622f605 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,7 +1,7 @@ -## Description - -Include a summary of the change. - -### Change Visualization - -Include a screenshot/video of before and after the change. +## Description + +Include a summary of the change. + +### Change Visualization + +Include a screenshot/video of before and after the change. diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index 0df6f9ec..1f6d9fbc 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -1,26 +1,26 @@ -name: Node.js CI - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -jobs: - build: - runs-on: macos-latest - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v6 - with: - node-version: 'latest' - - name: Install dependencies - run: npm ci - - name: Package unsigned mac app - run: npm run package:mac:nosign - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +name: Node.js CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + runs-on: macos-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v6 + with: + node-version: 'latest' + - name: Install dependencies + run: npm ci + - name: Package unsigned mac app + run: npm run package:mac:nosign + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b126ecce..856562bf 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,24 +1,24 @@ -name: Lint - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v6 - with: - node-version: 'latest' - - name: Install dependencies - run: npm ci - - name: Run ESLint - run: npx eslint . +name: Lint + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v6 + with: + node-version: 'latest' + - name: Install dependencies + run: npm ci + - name: Run ESLint + run: npx eslint . diff --git a/.github/workflows/release-draft.yml b/.github/workflows/release-draft.yml index e6ec7414..688d0bc2 100644 --- a/.github/workflows/release-draft.yml +++ b/.github/workflows/release-draft.yml @@ -1,33 +1,33 @@ -name: Draft release - -on: - push: - tags: - - "v*.*.*" - -permissions: - contents: write - -jobs: - release-draft: - runs-on: macos-latest - steps: - - name: Checkout code - uses: actions/checkout@v6 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v6 - with: - node-version: 'latest' - - name: Install dependencies - run: npm ci - - name: Package unsigned mac app - run: npm run package:mac:nosign - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: 'Create Draft release' - uses: softprops/action-gh-release@v2 - with: - token: ${{ secrets.GITHUB_TOKEN }} - files: ./release/*-arm64.dmg - draft: true - generate_release_notes: true +name: Draft release + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: write + +jobs: + release-draft: + runs-on: macos-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v6 + with: + node-version: 'latest' + - name: Install dependencies + run: npm ci + - name: Package unsigned mac app + run: npm run package:mac:nosign + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: 'Create Draft release' + uses: softprops/action-gh-release@v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + files: ./release/*-arm64.dmg + draft: true + generate_release_notes: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 57371e99..63ec4802 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,28 +1,28 @@ -name: Test - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -jobs: - test: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v6 - with: - node-version: 'latest' - - name: Install dependencies - run: npm ci - - name: Test Server - run: npm test --workspace=apps/server - - name: Test Frontend - run: npm test --workspace=apps/frontend - - name: Test Metrics - run: npm test --workspace=apps/metrics +name: Test + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v6 + with: + node-version: 'latest' + - name: Install dependencies + run: npm ci + - name: Test Server + run: npm test --workspace=apps/server + - name: Test Frontend + run: npm test --workspace=apps/frontend + - name: Test Metrics + run: npm test --workspace=apps/metrics diff --git a/.gitignore b/.gitignore index 154f6af6..df1e8d20 100644 --- a/.gitignore +++ b/.gitignore @@ -1,43 +1,43 @@ -# Dependencies -node_modules/ - -# Build outputs -build/ -dist/ -release/ - -# Logs -*.log -npm-debug.log* -*.rdb - -# Runtime data -pids -*.pid -*.seed - -# Coverage directory used by tools like istanbul -coverage/ - -# Environment variables -.env -.env.local -.env.development.local -.env.test.local -.env.production.local - -# OS generated files -.DS_Store -Thumbs.db - -# IDE files -.idea/ -*.swp -*.swo - -# Electron build outputs -out/ -app/ -packages/ - -apps/metrics/data/ +# Dependencies +node_modules/ + +# Build outputs +build/ +dist/ +release/ + +# Logs +*.log +npm-debug.log* +*.rdb + +# Runtime data +pids +*.pid +*.seed + +# Coverage directory used by tools like istanbul +coverage/ + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# OS generated files +.DS_Store +Thumbs.db + +# IDE files +.idea/ +*.swp +*.swo + +# Electron build outputs +out/ +app/ +packages/ + +apps/metrics/data/ diff --git a/.vscode/settings.json b/.vscode/settings.json index 0a28f906..29674ebe 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ -{ - "editor.codeActionsOnSave": { - "source.fixAll.eslint": "always" // Always ensure fixes on both explicit and auto saves. - } -} +{ + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "always" // Always ensure fixes on both explicit and auto saves. + } +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6f3fd5bb..1bcaf498 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,162 +1,162 @@ -# Contributing to Valkey Admin - -First off, thank you for taking the time to contribute! Contributions from the community help make Valkey Admin a powerful tool for everyone. To maintain the quality and architectural integrity of the project, we follow a structured contribution process. - ---- - -## Restrictions on Generative AI Usage -We expect authentic engagement in our community. - -If you use generative AI tools as an aid in developing code or documentation changes, ensure that you fully understand the proposed changes and can explain why they are the correct approach. - -If you do not fully understand some bits of the AI generated code, call out unknowns and assumptions. You should comment on these cases and point them out to reviewers so that they can use their knowledge of the codebase to clear up any concerns. For example, you might comment “calling this function here seems to work but I’m not familiar with how it works internally, I wonder if there’s a race condition if it is called concurrently”. - -Make sure you have added value based on your personal competency to your contributions. Just taking some input, feeding it to an AI and posting the result is not of value to the project. To preserve precious core developer capacity, we reserve the right to rigorously reject seemingly AI generated low-value contributions. - -## The RFC (Request for Comments) Process - -Before you start writing code for a new feature or a significant architectural change, you **must** create an RFC: - -1. **Open an Issue:** Create a new GitHub Issue with the prefix `[RFC] Your Feature Name`. -2. **Design Proposal:** Provide a general design or technical overview of your approach. Explain the *why* and the *how*. -3. **Tag a Maintainer:** Tag @arseny-kostenko, @ravjotbrar, @ArgusLi, and/or, @nassery318 -4. **Approval:** Wait for feedback and approval from the project contributors. -5. **Proceed:** Once the design is approved, you are cleared to begin development and submit a PR. - -*Note: Small bug fixes or documentation typos do not require an RFC.* - ---- - -## Technical Architecture & Patterns - -To ensure a maintainable and scalable codebase, please adhere to the following architectural patterns: - -### State Management & Components -* **Redux as Source of Truth:** Redux is the single source of truth for application state. -* **Presentational Components:** Prefer "dumb" or presentational components. These should focus on rendering Redux state and dispatching actions. Avoid embedding business logic directly within React components. -* **Local UI State:** React component state should be reserved strictly for local UI concerns (e.g., controlled inputs, toggle states). - -### Side Effects & Async Flows -We use **RxJS-based middleware (Epics)** to handle side effects and asynchronous logic. -* **Observable Pipelines:** Side effects are modeled as streams of actions. Epics should listen for specific actions and emit new actions using observable pipelines. -* **Pure Reducers:** Keep all side effects out of both components and reducers to maintain predictability. - -### Hooks Organization -* **Global Hooks:** The `/hooks` folder is strictly for global or truly reusable hooks shared across multiple components. -* **Local Hooks:** If a function or hook is used by only one component, it should live in a file adjacent to that component's file, not in the global directory. - -### Consistency -Before contributing, please take the time to familiarize yourself with the existing codebase and conventions. We value consistency in patterns and naming above all else. - ---- - -## Reporting Bugs & Feature Requests - -We use GitHub Issues to track bugs and suggest new features. - -* **Bugs:** Before opening an issue, please check if it has already been reported. When filing a bug, include your OS and steps to reproduce. -* **Feature Requests:** Open an issue describing the functionality you’d like to see and how it benefits Valkey Admin users. - ---- - -## Development Environment Setup - -### Desktop App Setup - -For the full-featured desktop application: - -1. **Install dependencies:** `npm install` -2. **Start Valkey cluster:** `./tools/valkey-cluster/scripts/build_run_cluster.sh` -3. **Build desktop app:** - - macOS: `npm run package:mac:nosign` - - Linux: `npm run package:linux:nosign` -4. **Launch app:** Find the built app in `release/` folder and launch it -5. **Connect:** Manually add a connection to `localhost:7001` - -### Web Development Setup - -For development servers (limited features - no hotkeys/commandlogs): - -1. **Install dependencies:** `npm install` -2. **Start Valkey cluster:** `./tools/valkey-cluster/scripts/build_run_cluster.sh` -3. **Start dev servers:** `npm run dev` or use `./quickstart-web.sh` -4. **Connect:** Open http://localhost:5173 and manually add connection to `localhost:7001` - -### Windows/WSL Users - -Fix line endings before running scripts: -```bash -sed -i 's/\r$//' tools/valkey-cluster/scripts/build_run_cluster.sh -sed -i 's/\r$//' tools/valkey-cluster/scripts/cluster_init.sh -chmod +x tools/valkey-cluster/scripts/*.sh -``` - -### Shutting Down - -```bash -cd tools/valkey-cluster -docker compose down -v -``` - -## IDE Setup - -### VSCode - -The repository includes settings for the ESLint extension. Please install it. - -**Note:** If you have a formatter i.e. Prettier, it could interfere with the ESLint extension. Please disable it from the workspace. - -This requires ESLint v9.0.0 and above. - -## Create DMG - -You are able to build notarized or non-notarized Applications. - -### Unnotarized Application - -#### Overview - - Much faster build process. - - While you won't encounter any issues running this on the system that built it, distributing the DMG will lead to a `"Valkey Admin" is damaged and can't be opened` error when running the application. To bypass this, run `xattr -c ` in terminal to disable the quarantine flag. - -#### Process -In the root directory, create a DMG by running `npm run package:mac:nosign`. - -### Notarized Application - -#### Overview - - Much slower build process (could be hours the first time, and up to 10 minutes consequently). - - Has additional requirements listed in `mac_build`. - -#### Process -In the root directory, create a DMG by running `npm run package:mac`. - -Note: you will see -``` -• skipped macOS notarization reason=`notarize` options were set explicitly `false` -``` -This is as we are not using electron builder's notarization tool, rather electron-notarize. - ---- - -## Coding Standards - -### Linting & Formatting -We use **ESLint v9.0.0+** to maintain code quality. -* **No Prettier:** Please **disable Prettier** in your IDE workspace for this project. It interferes with our ESLint configuration. -* **Automatic Linting:** We recommend the ESLint extension for VSCode. The repository includes settings to help you follow our style guide automatically. - ---- - -## Pull Request Process - -1. **Create a Branch:** Create a descriptively named feature branch from `main`. -2. **Commit Changes:** Write clear, concise commit messages. -3. **Sync with Upstream:** Ensure your branch is up to date with the main `valkey-admin` repository. -4. **Submit PR:** Open a Pull Request against the `main` branch. -5. **Approval:** All Pull Requests require at least one approval from a project contributor before they can be merged. - ---- - -## License - -By contributing to Valkey Admin, you agree that your contributions will be licensed under the **Apache License 2.0**. +# Contributing to Valkey Admin + +First off, thank you for taking the time to contribute! Contributions from the community help make Valkey Admin a powerful tool for everyone. To maintain the quality and architectural integrity of the project, we follow a structured contribution process. + +--- + +## Restrictions on Generative AI Usage +We expect authentic engagement in our community. + +If you use generative AI tools as an aid in developing code or documentation changes, ensure that you fully understand the proposed changes and can explain why they are the correct approach. + +If you do not fully understand some bits of the AI generated code, call out unknowns and assumptions. You should comment on these cases and point them out to reviewers so that they can use their knowledge of the codebase to clear up any concerns. For example, you might comment “calling this function here seems to work but I’m not familiar with how it works internally, I wonder if there’s a race condition if it is called concurrently”. + +Make sure you have added value based on your personal competency to your contributions. Just taking some input, feeding it to an AI and posting the result is not of value to the project. To preserve precious core developer capacity, we reserve the right to rigorously reject seemingly AI generated low-value contributions. + +## The RFC (Request for Comments) Process + +Before you start writing code for a new feature or a significant architectural change, you **must** create an RFC: + +1. **Open an Issue:** Create a new GitHub Issue with the prefix `[RFC] Your Feature Name`. +2. **Design Proposal:** Provide a general design or technical overview of your approach. Explain the *why* and the *how*. +3. **Tag a Maintainer:** Tag @arseny-kostenko, @ravjotbrar, @ArgusLi, and/or, @nassery318 +4. **Approval:** Wait for feedback and approval from the project contributors. +5. **Proceed:** Once the design is approved, you are cleared to begin development and submit a PR. + +*Note: Small bug fixes or documentation typos do not require an RFC.* + +--- + +## Technical Architecture & Patterns + +To ensure a maintainable and scalable codebase, please adhere to the following architectural patterns: + +### State Management & Components +* **Redux as Source of Truth:** Redux is the single source of truth for application state. +* **Presentational Components:** Prefer "dumb" or presentational components. These should focus on rendering Redux state and dispatching actions. Avoid embedding business logic directly within React components. +* **Local UI State:** React component state should be reserved strictly for local UI concerns (e.g., controlled inputs, toggle states). + +### Side Effects & Async Flows +We use **RxJS-based middleware (Epics)** to handle side effects and asynchronous logic. +* **Observable Pipelines:** Side effects are modeled as streams of actions. Epics should listen for specific actions and emit new actions using observable pipelines. +* **Pure Reducers:** Keep all side effects out of both components and reducers to maintain predictability. + +### Hooks Organization +* **Global Hooks:** The `/hooks` folder is strictly for global or truly reusable hooks shared across multiple components. +* **Local Hooks:** If a function or hook is used by only one component, it should live in a file adjacent to that component's file, not in the global directory. + +### Consistency +Before contributing, please take the time to familiarize yourself with the existing codebase and conventions. We value consistency in patterns and naming above all else. + +--- + +## Reporting Bugs & Feature Requests + +We use GitHub Issues to track bugs and suggest new features. + +* **Bugs:** Before opening an issue, please check if it has already been reported. When filing a bug, include your OS and steps to reproduce. +* **Feature Requests:** Open an issue describing the functionality you’d like to see and how it benefits Valkey Admin users. + +--- + +## Development Environment Setup + +### Desktop App Setup + +For the full-featured desktop application: + +1. **Install dependencies:** `npm install` +2. **Start Valkey cluster:** `./tools/valkey-cluster/scripts/build_run_cluster.sh` +3. **Build desktop app:** + - macOS: `npm run package:mac:nosign` + - Linux: `npm run package:linux:nosign` +4. **Launch app:** Find the built app in `release/` folder and launch it +5. **Connect:** Manually add a connection to `localhost:7001` + +### Web Development Setup + +For development servers (limited features - no hotkeys/commandlogs): + +1. **Install dependencies:** `npm install` +2. **Start Valkey cluster:** `./tools/valkey-cluster/scripts/build_run_cluster.sh` +3. **Start dev servers:** `npm run dev` or use `./quickstart-web.sh` +4. **Connect:** Open http://localhost:5173 and manually add connection to `localhost:7001` + +### Windows/WSL Users + +Fix line endings before running scripts: +```bash +sed -i 's/\r$//' tools/valkey-cluster/scripts/build_run_cluster.sh +sed -i 's/\r$//' tools/valkey-cluster/scripts/cluster_init.sh +chmod +x tools/valkey-cluster/scripts/*.sh +``` + +### Shutting Down + +```bash +cd tools/valkey-cluster +docker compose down -v +``` + +## IDE Setup + +### VSCode + +The repository includes settings for the ESLint extension. Please install it. + +**Note:** If you have a formatter i.e. Prettier, it could interfere with the ESLint extension. Please disable it from the workspace. + +This requires ESLint v9.0.0 and above. + +## Create DMG + +You are able to build notarized or non-notarized Applications. + +### Unnotarized Application + +#### Overview + - Much faster build process. + - While you won't encounter any issues running this on the system that built it, distributing the DMG will lead to a `"Valkey Admin" is damaged and can't be opened` error when running the application. To bypass this, run `xattr -c ` in terminal to disable the quarantine flag. + +#### Process +In the root directory, create a DMG by running `npm run package:mac:nosign`. + +### Notarized Application + +#### Overview + - Much slower build process (could be hours the first time, and up to 10 minutes consequently). + - Has additional requirements listed in `mac_build`. + +#### Process +In the root directory, create a DMG by running `npm run package:mac`. + +Note: you will see +``` +• skipped macOS notarization reason=`notarize` options were set explicitly `false` +``` +This is as we are not using electron builder's notarization tool, rather electron-notarize. + +--- + +## Coding Standards + +### Linting & Formatting +We use **ESLint v9.0.0+** to maintain code quality. +* **No Prettier:** Please **disable Prettier** in your IDE workspace for this project. It interferes with our ESLint configuration. +* **Automatic Linting:** We recommend the ESLint extension for VSCode. The repository includes settings to help you follow our style guide automatically. + +--- + +## Pull Request Process + +1. **Create a Branch:** Create a descriptively named feature branch from `main`. +2. **Commit Changes:** Write clear, concise commit messages. +3. **Sync with Upstream:** Ensure your branch is up to date with the main `valkey-admin` repository. +4. **Submit PR:** Open a Pull Request against the `main` branch. +5. **Approval:** All Pull Requests require at least one approval from a project contributor before they can be merged. + +--- + +## License + +By contributing to Valkey Admin, you agree that your contributions will be licensed under the **Apache License 2.0**. diff --git a/LICENSE b/LICENSE index f49a4e16..753842b6 100644 --- a/LICENSE +++ b/LICENSE @@ -1,201 +1,201 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md index 4fa360e1..dc521083 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,71 @@ -# Valkey Admin - -## What is Valkey Admin? - -Valkey Admin is a web-based administration tool for Valkey clusters. It provides an intuitive interface to monitor, manage, and interact with your Valkey instances, offering features like real-time metrics and key management. - -![Dashboard](screenshots/dashboard.png) - -![Key Browser](screenshots/key_browser.png) - -![Send Command](screenshots/command.png) - -![Cluster Topology](screenshots/cluster_topology.png) - -Built with React and TypeScript, Valkey Admin runs as a desktop application via Electron. Some features like hotkeys and commandlogs rely on Electron, so the app is currently only fully supported as a desktop app. Use the web application for a subset of features. - -![Monitoring Hot Keys](screenshots/monitoring_hot_keys.png) - -![Monitoring Slow Logs](screenshots/monitoring_slow_logs.png) - -![Monitoring Large Requests](screenshots/monitoring_large_requests.png) - -![Monitoring Large Replies](screenshots/monitoring_large_replies.png) - -## Platform Support - -Valkey Admin works on: -- **macOS** (native support) -- **Linux** (native support) -- **Windows** (via WSL - Windows Subsystem for Linux) - -## Quick Start - -```bash -./quickstart.sh -``` - -This builds the full desktop application with all features (hotkeys, commandlogs, etc.). The app will be built in the `release/` folder with connection instructions. - -**For web development only:** Use `./quickstart-web.sh` for the development servers (limited features). - -### Running the Built Desktop App - -After building, launch the desktop app: - -**macOS:** -```bash -open "release/Valkey Admin.app" -``` - -**Linux:** -```bash -# Make executable and run AppImage -chmod +x "release/Valkey Admin-${VERSION}.AppImage" -./release/Valkey\ Admin-${VERSION}.AppImage - -# Or install DEB package -sudo dpkg -i "release/valkey-admin_${VERSION}_amd64.deb" -valkey-admin -``` - -**Windows:** The desktop app builds for Linux/macOS only. Use `./quickstart-web.sh` for web interface. - -### Manual Connection -Once the app is running, manually add a connection to your cluster (default local cluster is usually `localhost:7001`). - -## Contributing -Interested in improving Valkey Admin? Please see our [CONTRIBUTING.md](./CONTRIBUTING.md) for environment setup, WSL instructions, and development workflows. - -## License -Valkey Admin is released under the **Apache License 2.0**. +# Valkey Admin + +## What is Valkey Admin? + +Valkey Admin is a web-based administration tool for Valkey clusters. It provides an intuitive interface to monitor, manage, and interact with your Valkey instances, offering features like real-time metrics and key management. + +![Dashboard](screenshots/dashboard.png) + +![Key Browser](screenshots/key_browser.png) + +![Send Command](screenshots/command.png) + +![Cluster Topology](screenshots/cluster_topology.png) + +Built with React and TypeScript, Valkey Admin runs as a desktop application via Electron. Some features like hotkeys and commandlogs rely on Electron, so the app is currently only fully supported as a desktop app. Use the web application for a subset of features. + +![Monitoring Hot Keys](screenshots/monitoring_hot_keys.png) + +![Monitoring Slow Logs](screenshots/monitoring_slow_logs.png) + +![Monitoring Large Requests](screenshots/monitoring_large_requests.png) + +![Monitoring Large Replies](screenshots/monitoring_large_replies.png) + +## Platform Support + +Valkey Admin works on: +- **macOS** (native support) +- **Linux** (native support) +- **Windows** (via WSL - Windows Subsystem for Linux) + +## Quick Start + +```bash +./quickstart.sh +``` + +This builds the full desktop application with all features (hotkeys, commandlogs, etc.). The app will be built in the `release/` folder with connection instructions. + +**For web development only:** Use `./quickstart-web.sh` for the development servers (limited features). + +### Running the Built Desktop App + +After building, launch the desktop app: + +**macOS:** +```bash +open "release/Valkey Admin.app" +``` + +**Linux:** +```bash +# Make executable and run AppImage +chmod +x "release/Valkey Admin-${VERSION}.AppImage" +./release/Valkey\ Admin-${VERSION}.AppImage + +# Or install DEB package +sudo dpkg -i "release/valkey-admin_${VERSION}_amd64.deb" +valkey-admin +``` + +**Windows:** The desktop app builds for Linux/macOS only. Use `./quickstart-web.sh` for web interface. + +### Manual Connection +Once the app is running, manually add a connection to your cluster (default local cluster is usually `localhost:7001`). + +## Contributing +Interested in improving Valkey Admin? Please see our [CONTRIBUTING.md](./CONTRIBUTING.md) for environment setup, WSL instructions, and development workflows. + +## License +Valkey Admin is released under the **Apache License 2.0**. diff --git a/SECURITY.md b/SECURITY.md index d19c7c2b..6b75478e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,5 @@ -# Reporting a Vulnerability - -If you believe you've discovered a security vulnerability, please contact the Valkey team at security@lists.valkey.io. -Please *DO NOT* create an issue. -We follow a responsible disclosure procedure, so depending on the severity of the issue we may notify Valkey vendors about the issue before releasing it publicly. +# Reporting a Vulnerability + +If you believe you've discovered a security vulnerability, please contact the Valkey team at security@lists.valkey.io. +Please *DO NOT* create an issue. +We follow a responsible disclosure procedure, so depending on the severity of the issue we may notify Valkey vendors about the issue before releasing it publicly. diff --git a/apps/frontend/electron.main.js b/apps/frontend/electron.main.js index 786b3736..28ee65e5 100644 --- a/apps/frontend/electron.main.js +++ b/apps/frontend/electron.main.js @@ -1,240 +1,240 @@ -/* eslint-disable @typescript-eslint/no-require-imports */ -const { app, BrowserWindow, ipcMain, safeStorage, shell, powerMonitor } = require("electron") -const path = require("path") -const { fork } = require("child_process") -const { createApplicationMenu } = require("./menu") - -let serverProcess -const metricsProcesses = new Map() - -function startServer() { - if (app.isPackaged) { - const serverPath = path.join(process.resourcesPath, "server-backend.js") - console.log(`Starting backend server from: ${serverPath}`) - serverProcess = fork(serverPath) - - serverProcess.on("close", (code) => { - console.log(`Backend server exited with code ${code}`) - }) - serverProcess.on("error", (err) => { - console.error(`Backend server error: ${err}`) - }) - } -} - -function startMetricsForClusterNode(clusterNodes, connectionId) { - const node = clusterNodes[connectionId] - const connectionDetails = { - host: node.host, - port: node.port, - username: node.username, - password: node.password, - tls: node.tls, - verifyTlsCertificate: node.verifyTlsCertificate, - } - if (!metricsProcesses.has(connectionId)) { - startMetrics(connectionId, connectionDetails) - } -} - -function startMetrics(serverConnectionId, serverConnectionDetails) { - const dataDir = path.join(app.getPath("userData"), "metrics-data", serverConnectionId) - - let metricsServerPath - let configPath - - const { host, port, username, password, tls, verifyTlsCertificate } = serverConnectionDetails - - if (app.isPackaged) { - metricsServerPath = path.join(process.resourcesPath, "server-metrics.js") - configPath = path.join(process.resourcesPath, "config.yml") // Path for production - } else { - metricsServerPath = path.join(__dirname, "../../metrics/src/index.js") - configPath = path.join(__dirname, "../../metrics/config.yml") // Path for development - } - - const metricsProcess = fork(metricsServerPath, [], { - env: { - ...process.env, - PORT: 0, - DATA_DIR: dataDir, - VALKEY_HOST: host, - VALKEY_PORT: port, - VALKEY_USERNAME: username, - VALKEY_PASSWORD: password, - VALKEY_TLS: tls, - VALKEY_VERIFY_CERT: verifyTlsCertificate, - CONFIG_PATH: configPath, // Explicitly provide the config path - }, - }) - - metricsProcesses.set(serverConnectionId, metricsProcess) - - metricsProcess.on("message", (message) => { - if (message && message.type === "metrics-started") { - console.log(`Metrics server for ${serverConnectionId} started successfully on host: ${message.payload.metricsHost} port ${message.payload.metricsPort}`) - serverProcess.send?.({ - ...message, - payload: { - ...message.payload, - serverConnectionId: serverConnectionId, - }, - }) - } - if (message.type === "close-client") { - const { connectionId } = message.payload - console.debug(`Stopping metrics server for ${connectionId}`) - stopMetricServer(connectionId) - } - }) - - metricsProcess.on("close", (code) => { - console.log(`Metrics server for connection ${serverConnectionId} exited with code ${code}`) - metricsProcesses.delete(serverConnectionId) - serverProcess.send({ - type: "metrics-closed", - payload: { - serverConnectionId, - }, - }) - }) - - metricsProcess.on("error", (err) => { - console.error(`Metrics server for connection ${serverConnectionId} error: ${err}`) - }) -} - -function stopMetricServer(serverConnectionId) { - try { - console.log("Killing metrics server for ", serverConnectionId) - metricsProcesses.get(serverConnectionId).kill() - metricsProcesses.delete(serverConnectionId) - } - catch (e) { - console.warn(`Failed to kill metrics server ${serverConnectionId}:`, e) - } -} - -function stopMetricServers() { - metricsProcesses.forEach((metricProcess, serverConnectionId) => { - try { - metricProcess.kill() - } catch (e) { - console.warn(`Failed to kill metrics server ${serverConnectionId}:`, e) - } - }) - metricsProcesses.clear() -} - -function createWindow() { - const win = new BrowserWindow({ - width: 1200, - height: 800, - minWidth: 1200, - minHeight: 800, - webPreferences: { - nodeIntegration: false, - contextIsolation: true, - preload: path.join(__dirname, "preload.js"), - }, - }) - - // Open external links in default browser - win.webContents.setWindowOpenHandler(({ url }) => { - shell.openExternal(url) - return { action: "deny" } - }) - - if (app.isPackaged) { - win.loadFile(path.join(__dirname, "dist", "index.html")) - } else { - win.loadURL("http://localhost:5173") - win.webContents.openDevTools() - } -} - -app.whenReady().then(() => { - createApplicationMenu() - startServer() - if (serverProcess) { - serverProcess.on("message", (message) => { - switch (message.type) { - case "websocket-ready": - createWindow() - break - case "valkeyConnection/standaloneConnectFulfilled": - startMetrics(message.payload.connectionId, message.payload.connectionDetails) - break - case "valkeyConnection/clusterConnectFulfilled": - startMetricsForClusterNode(message.payload.clusterNodes, message.payload.connectionId) - break - default: - try { - console.warn(`Received unknown server message: ${JSON.stringify(message)}`) - } catch (e) { - console.error(`Received unknown server message: ${message}. Error: `, e) - } - - } - }) - } else { - createWindow() - } -}) - -app.on("window-all-closed", () => { - if (process.platform !== "darwin") { - app.quit() - } -}) - -app.on("before-quit", () => { - cleanupAndExit() -}) - -app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) { - createWindow() - } -}) - -powerMonitor.on("suspend", () => { - console.log("System suspending") - serverProcess.send({ - type: "system-suspended", - }) - -}) - -powerMonitor.on("resume", () => { - console.log("System resumed") - serverProcess.send({ - type: "system-resumed", - }) -}) - -ipcMain.handle("secure-storage:encrypt", async (event, password) => { - if (!password || !safeStorage.isEncryptionAvailable()) return password - const encrypted = safeStorage.encryptString(password) - return encrypted.toString("base64") -}) - -ipcMain.handle("secure-storage:decrypt", async (event, encryptedBase64) => { - if (!encryptedBase64 || !safeStorage.isEncryptionAvailable()) return "" - try { - const encrypted = Buffer.from(encryptedBase64, "base64") - return safeStorage.decryptString(encrypted) - } catch { - return "" // TODO: Look into this case more closely - } -}) - -process.on("SIGINT", cleanupAndExit) -process.on("SIGTERM", cleanupAndExit) - -function cleanupAndExit() { - console.log("Cleaning up ...") - if (serverProcess) serverProcess.kill() - if (metricsProcesses.size > 0) stopMetricServers() - setTimeout(() => process.exit(0), 100) -} +/* eslint-disable @typescript-eslint/no-require-imports */ +const { app, BrowserWindow, ipcMain, safeStorage, shell, powerMonitor } = require("electron") +const path = require("path") +const { fork } = require("child_process") +const { createApplicationMenu } = require("./menu") + +let serverProcess +const metricsProcesses = new Map() + +function startServer() { + if (app.isPackaged) { + const serverPath = path.join(process.resourcesPath, "server-backend.js") + console.log(`Starting backend server from: ${serverPath}`) + serverProcess = fork(serverPath) + + serverProcess.on("close", (code) => { + console.log(`Backend server exited with code ${code}`) + }) + serverProcess.on("error", (err) => { + console.error(`Backend server error: ${err}`) + }) + } +} + +function startMetricsForClusterNode(clusterNodes, connectionId) { + const node = clusterNodes[connectionId] + const connectionDetails = { + host: node.host, + port: node.port, + username: node.username, + password: node.password, + tls: node.tls, + verifyTlsCertificate: node.verifyTlsCertificate, + } + if (!metricsProcesses.has(connectionId)) { + startMetrics(connectionId, connectionDetails) + } +} + +function startMetrics(serverConnectionId, serverConnectionDetails) { + const dataDir = path.join(app.getPath("userData"), "metrics-data", serverConnectionId) + + let metricsServerPath + let configPath + + const { host, port, username, password, tls, verifyTlsCertificate } = serverConnectionDetails + + if (app.isPackaged) { + metricsServerPath = path.join(process.resourcesPath, "server-metrics.js") + configPath = path.join(process.resourcesPath, "config.yml") // Path for production + } else { + metricsServerPath = path.join(__dirname, "../../metrics/src/index.js") + configPath = path.join(__dirname, "../../metrics/config.yml") // Path for development + } + + const metricsProcess = fork(metricsServerPath, [], { + env: { + ...process.env, + PORT: 0, + DATA_DIR: dataDir, + VALKEY_HOST: host, + VALKEY_PORT: port, + VALKEY_USERNAME: username, + VALKEY_PASSWORD: password, + VALKEY_TLS: tls, + VALKEY_VERIFY_CERT: verifyTlsCertificate, + CONFIG_PATH: configPath, // Explicitly provide the config path + }, + }) + + metricsProcesses.set(serverConnectionId, metricsProcess) + + metricsProcess.on("message", (message) => { + if (message && message.type === "metrics-started") { + console.log(`Metrics server for ${serverConnectionId} started successfully on host: ${message.payload.metricsHost} port ${message.payload.metricsPort}`) + serverProcess.send?.({ + ...message, + payload: { + ...message.payload, + serverConnectionId: serverConnectionId, + }, + }) + } + if (message.type === "close-client") { + const { connectionId } = message.payload + console.debug(`Stopping metrics server for ${connectionId}`) + stopMetricServer(connectionId) + } + }) + + metricsProcess.on("close", (code) => { + console.log(`Metrics server for connection ${serverConnectionId} exited with code ${code}`) + metricsProcesses.delete(serverConnectionId) + serverProcess.send({ + type: "metrics-closed", + payload: { + serverConnectionId, + }, + }) + }) + + metricsProcess.on("error", (err) => { + console.error(`Metrics server for connection ${serverConnectionId} error: ${err}`) + }) +} + +function stopMetricServer(serverConnectionId) { + try { + console.log("Killing metrics server for ", serverConnectionId) + metricsProcesses.get(serverConnectionId).kill() + metricsProcesses.delete(serverConnectionId) + } + catch (e) { + console.warn(`Failed to kill metrics server ${serverConnectionId}:`, e) + } +} + +function stopMetricServers() { + metricsProcesses.forEach((metricProcess, serverConnectionId) => { + try { + metricProcess.kill() + } catch (e) { + console.warn(`Failed to kill metrics server ${serverConnectionId}:`, e) + } + }) + metricsProcesses.clear() +} + +function createWindow() { + const win = new BrowserWindow({ + width: 1200, + height: 800, + minWidth: 1200, + minHeight: 800, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + preload: path.join(__dirname, "preload.js"), + }, + }) + + // Open external links in default browser + win.webContents.setWindowOpenHandler(({ url }) => { + shell.openExternal(url) + return { action: "deny" } + }) + + if (app.isPackaged) { + win.loadFile(path.join(__dirname, "dist", "index.html")) + } else { + win.loadURL("http://localhost:5173") + win.webContents.openDevTools() + } +} + +app.whenReady().then(() => { + createApplicationMenu() + startServer() + if (serverProcess) { + serverProcess.on("message", (message) => { + switch (message.type) { + case "websocket-ready": + createWindow() + break + case "valkeyConnection/standaloneConnectFulfilled": + startMetrics(message.payload.connectionId, message.payload.connectionDetails) + break + case "valkeyConnection/clusterConnectFulfilled": + startMetricsForClusterNode(message.payload.clusterNodes, message.payload.connectionId) + break + default: + try { + console.warn(`Received unknown server message: ${JSON.stringify(message)}`) + } catch (e) { + console.error(`Received unknown server message: ${message}. Error: `, e) + } + + } + }) + } else { + createWindow() + } +}) + +app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit() + } +}) + +app.on("before-quit", () => { + cleanupAndExit() +}) + +app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow() + } +}) + +powerMonitor.on("suspend", () => { + console.log("System suspending") + serverProcess.send({ + type: "system-suspended", + }) + +}) + +powerMonitor.on("resume", () => { + console.log("System resumed") + serverProcess.send({ + type: "system-resumed", + }) +}) + +ipcMain.handle("secure-storage:encrypt", async (event, password) => { + if (!password || !safeStorage.isEncryptionAvailable()) return password + const encrypted = safeStorage.encryptString(password) + return encrypted.toString("base64") +}) + +ipcMain.handle("secure-storage:decrypt", async (event, encryptedBase64) => { + if (!encryptedBase64 || !safeStorage.isEncryptionAvailable()) return "" + try { + const encrypted = Buffer.from(encryptedBase64, "base64") + return safeStorage.decryptString(encrypted) + } catch { + return "" // TODO: Look into this case more closely + } +}) + +process.on("SIGINT", cleanupAndExit) +process.on("SIGTERM", cleanupAndExit) + +function cleanupAndExit() { + console.log("Cleaning up ...") + if (serverProcess) serverProcess.kill() + if (metricsProcesses.size > 0) stopMetricServers() + setTimeout(() => process.exit(0), 100) +} diff --git a/apps/frontend/index.html b/apps/frontend/index.html index 0867878f..a099fd85 100644 --- a/apps/frontend/index.html +++ b/apps/frontend/index.html @@ -1,13 +1,13 @@ - - - - - - - Valkey Admin - - -
- - - + + + + + + + Valkey Admin + + +
+ + + diff --git a/apps/frontend/menu.js b/apps/frontend/menu.js index 14f0f696..bb7bbcbe 100644 --- a/apps/frontend/menu.js +++ b/apps/frontend/menu.js @@ -1,46 +1,46 @@ -/* eslint-disable @typescript-eslint/no-require-imports */ -const { Menu, BrowserWindow, shell } = require("electron") - -function createApplicationMenu() { - const isMac = process.platform === "darwin" - - const template = [ - ...(isMac ? [{ role: "appMenu" }] : []), - { role: "fileMenu" }, - { role: "editMenu" }, - { role: "viewMenu" }, - { - label: "Shortcuts", - submenu: [ - { label: "Connections", accelerator: isMac ? "Cmd+1" : "Ctrl+1", click: () => sendNavigationEvent("connect") }, - { label: "Dashboard", accelerator: isMac ? "Cmd+2" : "Ctrl+2", click: () => sendNavigationEvent("dashboard") }, - { label: "Key Browser", accelerator: isMac ? "Cmd+3" : "Ctrl+3", click: () => sendNavigationEvent("browse") }, - { label: "Monitoring", accelerator: isMac ? "Cmd+4" : "Ctrl+4", click: () => sendNavigationEvent("monitoring") }, - { label: "Send Command", accelerator: isMac ? "Cmd+5" : "Ctrl+5", click: () => sendNavigationEvent("sendcommand") }, - { label: "Cluster Topology", accelerator: isMac ? "Cmd+6" : "Ctrl+6", click: () => sendNavigationEvent("cluster-topology") }, - { label: "Settings", accelerator: isMac ? "Cmd+7" : "Ctrl+7", click: () => sendNavigationEvent("settings") }, - { label: "Learn More", accelerator: isMac ? "Cmd+8" : "Ctrl+8", click: () => sendNavigationEvent("learnmore") }, - - ], - }, - { role: "windowMenu" }, - { - role: "help", - submenu: [ - { label: "GitHub Repository", click: async () => { await shell.openExternal("https://github.com/valkey-io/valkey-admin") } }, - ], - }, - ] - - const menu = Menu.buildFromTemplate(template) - Menu.setApplicationMenu(menu) -} - -function sendNavigationEvent(route) { - const win = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0] - if (win) { - win.webContents.send("navigate", route) - } -} - -module.exports = { createApplicationMenu } +/* eslint-disable @typescript-eslint/no-require-imports */ +const { Menu, BrowserWindow, shell } = require("electron") + +function createApplicationMenu() { + const isMac = process.platform === "darwin" + + const template = [ + ...(isMac ? [{ role: "appMenu" }] : []), + { role: "fileMenu" }, + { role: "editMenu" }, + { role: "viewMenu" }, + { + label: "Shortcuts", + submenu: [ + { label: "Connections", accelerator: isMac ? "Cmd+1" : "Ctrl+1", click: () => sendNavigationEvent("connect") }, + { label: "Dashboard", accelerator: isMac ? "Cmd+2" : "Ctrl+2", click: () => sendNavigationEvent("dashboard") }, + { label: "Key Browser", accelerator: isMac ? "Cmd+3" : "Ctrl+3", click: () => sendNavigationEvent("browse") }, + { label: "Monitoring", accelerator: isMac ? "Cmd+4" : "Ctrl+4", click: () => sendNavigationEvent("monitoring") }, + { label: "Send Command", accelerator: isMac ? "Cmd+5" : "Ctrl+5", click: () => sendNavigationEvent("sendcommand") }, + { label: "Cluster Topology", accelerator: isMac ? "Cmd+6" : "Ctrl+6", click: () => sendNavigationEvent("cluster-topology") }, + { label: "Settings", accelerator: isMac ? "Cmd+7" : "Ctrl+7", click: () => sendNavigationEvent("settings") }, + { label: "Learn More", accelerator: isMac ? "Cmd+8" : "Ctrl+8", click: () => sendNavigationEvent("learnmore") }, + + ], + }, + { role: "windowMenu" }, + { + role: "help", + submenu: [ + { label: "GitHub Repository", click: async () => { await shell.openExternal("https://github.com/valkey-io/valkey-admin") } }, + ], + }, + ] + + const menu = Menu.buildFromTemplate(template) + Menu.setApplicationMenu(menu) +} + +function sendNavigationEvent(route) { + const win = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0] + if (win) { + win.webContents.send("navigate", route) + } +} + +module.exports = { createApplicationMenu } diff --git a/apps/frontend/package.json b/apps/frontend/package.json index ee9a156f..cf0b4594 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -1,54 +1,55 @@ -{ - "name": "frontend", - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview", - "lint": "eslint .", - "test": "vitest run", - "test:ui": "vitest --ui", - "test:run": "vitest run", - "test:coverage": "vitest run --coverage", - "test:watch": "vitest watch" - }, - "dependencies": { - "@fontsource-variable/inter": "^5.2.8", - "@fontsource/geist-mono": "^5.2.7", - "@radix-ui/react-dialog": "^1.1.14", - "@radix-ui/react-label": "^2.1.7", - "@radix-ui/react-separator": "^1.1.7", - "@radix-ui/react-slot": "^1.2.3", - "@radix-ui/react-tooltip": "^1.2.7", - "@reduxjs/toolkit": "^2.8.2", - "@tailwindcss/vite": "^4.1.11", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "lucide-react": "^0.541.0", - "next-themes": "^0.4.6", - "ramda": "^0.31.3", - "react": "^19.1.0", - "react-dom": "^19.1.0", - "react-redux": "^9.2.0", - "react-router": "^7.8.0", - "recharts": "^3.3.0", - "rxjs": "^7.8.1", - "sonner": "^2.0.7", - "tailwind-merge": "^3.3.1", - "tailwindcss": "^4.1.11" - }, - "devDependencies": { - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.1", - "@testing-library/user-event": "^14.6.1", - "@types/ramda": "^0.31.0", - "@types/react": "^19.1.8", - "@types/react-dom": "^19.1.6", - "@vitejs/plugin-react": "^4.6.0", - "jsdom": "^25.0.1", - "tw-animate-css": "^1.3.6", - "vite": "^7.0.4", - "vitest": "^4.0.16" - } -} +{ + "name": "frontend", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "lint": "eslint .", + "test": "vitest run", + "test:ui": "vitest --ui", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage", + "test:watch": "vitest watch" + }, + "dependencies": { + "@fontsource-variable/inter": "^5.2.8", + "@fontsource/geist-mono": "^5.2.7", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tooltip": "^1.2.7", + "@reduxjs/toolkit": "^2.8.2", + "@tailwindcss/vite": "^4.1.11", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "fastest-levenshtein": "^1.0.16", + "lucide-react": "^0.541.0", + "next-themes": "^0.4.6", + "ramda": "^0.31.3", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-redux": "^9.2.0", + "react-router": "^7.8.0", + "recharts": "^3.3.0", + "rxjs": "^7.8.1", + "sonner": "^2.0.7", + "tailwind-merge": "^3.3.1", + "tailwindcss": "^4.1.11" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.1", + "@testing-library/user-event": "^14.6.1", + "@types/ramda": "^0.31.0", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.6.0", + "jsdom": "^25.0.1", + "tw-animate-css": "^1.3.6", + "vite": "^7.0.4", + "vitest": "^4.0.16" + } +} diff --git a/apps/frontend/preload.js b/apps/frontend/preload.js index c081778d..98368763 100644 --- a/apps/frontend/preload.js +++ b/apps/frontend/preload.js @@ -1,13 +1,13 @@ -// eslint-disable-next-line @typescript-eslint/no-require-imports -const { contextBridge, ipcRenderer } = require("electron") - -contextBridge.exposeInMainWorld("secureStorage", { - encrypt: (password) => ipcRenderer.invoke("secure-storage:encrypt", password), - decrypt: (encrypted) => ipcRenderer.invoke("secure-storage:decrypt", encrypted), -}) - -contextBridge.exposeInMainWorld("electronNavigation", { - onNavigate: (callback) => { - ipcRenderer.on("navigate", (_event, route) => callback(route)) - }, -}) +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { contextBridge, ipcRenderer } = require("electron") + +contextBridge.exposeInMainWorld("secureStorage", { + encrypt: (password) => ipcRenderer.invoke("secure-storage:encrypt", password), + decrypt: (encrypted) => ipcRenderer.invoke("secure-storage:decrypt", encrypted), +}) + +contextBridge.exposeInMainWorld("electronNavigation", { + onNavigate: (callback) => { + ipcRenderer.on("navigate", (_event, route) => callback(route)) + }, +}) diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 949e7c4a..3d039cf3 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -1,39 +1,39 @@ -import { useEffect } from "react" -import { useDispatch } from "react-redux" -import { Outlet } from "react-router" -import { SidebarInset, SidebarProvider } from "./components/ui/sidebar" -import { AppSidebar } from "./components/ui/app-sidebar" -import { Toaster } from "./components/ui/sonner" -import { DarkModeProvider } from "./contexts/DarkModeContext" -import { useWebSocketNavigation } from "./hooks/useWebSocketNavigation" -import { useValkeyConnectionNavigation } from "./hooks/useValkeyConnectionNavigation" -import { useShortcutNavigation } from "./hooks/useShortcutNavigation" -import { connectPending } from "@/state/wsconnection/wsConnectionSlice" - -function App() { - const dispatch = useDispatch() - - useWebSocketNavigation() - useValkeyConnectionNavigation() - useShortcutNavigation() - - useEffect(() => { - dispatch(connectPending()) - }, [dispatch]) - - return ( - -
- - - - - - - -
-
- ) -} - -export default App +import { useEffect } from "react" +import { useDispatch } from "react-redux" +import { Outlet } from "react-router" +import { SidebarInset, SidebarProvider } from "./components/ui/sidebar" +import { AppSidebar } from "./components/ui/app-sidebar" +import { Toaster } from "./components/ui/sonner" +import { DarkModeProvider } from "./contexts/DarkModeContext" +import { useWebSocketNavigation } from "./hooks/useWebSocketNavigation" +import { useValkeyConnectionNavigation } from "./hooks/useValkeyConnectionNavigation" +import { useShortcutNavigation } from "./hooks/useShortcutNavigation" +import { connectPending } from "@/state/wsconnection/wsConnectionSlice" + +function App() { + const dispatch = useDispatch() + + useWebSocketNavigation() + useValkeyConnectionNavigation() + useShortcutNavigation() + + useEffect(() => { + dispatch(connectPending()) + }, [dispatch]) + + return ( + +
+ + + + + + + +
+
+ ) +} + +export default App diff --git a/apps/frontend/src/__tests__/text-insertion.test.ts b/apps/frontend/src/__tests__/text-insertion.test.ts new file mode 100644 index 00000000..90ed8360 --- /dev/null +++ b/apps/frontend/src/__tests__/text-insertion.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest" +import type { ValkeyCommand } from "@/types/valkey-commands" +import { insertCommandIntoText, extractCommandFromText } from "@/utils/text-insertion" + +describe("text-insertion", () => { + describe("extractCommandFromText", () => { + it("should extract command from simple text", () => { + const result = extractCommandFromText("GET", 3) + expect(result).toBe("GET") + }) + + it("should extract partial command", () => { + const result = extractCommandFromText("GE", 2) + expect(result).toBe("GE") + }) + + it("should extract command with arguments", () => { + const result = extractCommandFromText("GET mykey", 3) + expect(result).toBe("GET") + }) + + it("should extract command from multi-line text", () => { + const result = extractCommandFromText("SET key1 value1\nGET", 19) + expect(result).toBe("GET") + }) + + it("should handle whitespace before command", () => { + const result = extractCommandFromText(" GET", 5) + expect(result).toBe("GET") + }) + + it("should return empty string for empty text", () => { + const result = extractCommandFromText("", 0) + expect(result).toBe("") + }) + }) + + describe("insertCommandIntoText", () => { + const getCommand: ValkeyCommand = { + name: "GET", + syntax: "GET key", + category: "string", + description: "Get the value of a key", + parameters: [{ name: "key", type: "key", required: true, placeholder: "key" }], + tier: "read", + } + + const pingCommand: ValkeyCommand = { + name: "PING", + syntax: "PING", + category: "connection", + description: "Ping the server", + parameters: [], + tier: "read", + } + + it("should insert command without placeholder", () => { + const result = insertCommandIntoText("G", 1, getCommand) + expect(result.newText).toBe("GET") + expect(result.newCursorPosition).toBe(3) + }) + + it("should preserve existing arguments", () => { + const result = insertCommandIntoText("G mykey", 1, getCommand) + expect(result.newText).toBe("GET mykey") + expect(result.newCursorPosition).toBe(9) + }) + + it("should insert command without parameters", () => { + const result = insertCommandIntoText("PIN", 3, pingCommand) + expect(result.newText).toBe("PING") + expect(result.newCursorPosition).toBe(4) + }) + + it("should handle multi-line text", () => { + const result = insertCommandIntoText("SET key1 value1\nG", 17, getCommand) + expect(result.newText).toBe("SET key1 value1\nGET") + expect(result.newCursorPosition).toBe(19) + }) + + it("should handle whitespace before command", () => { + const result = insertCommandIntoText(" G", 3, getCommand) + expect(result.newText).toBe(" GET") + expect(result.newCursorPosition).toBe(5) + }) + + it("should replace partial command on same line", () => { + const result = insertCommandIntoText("GE", 2, getCommand) + expect(result.newText).toBe("GET") + expect(result.newCursorPosition).toBe(3) + }) + }) +}) diff --git a/apps/frontend/src/hooks/useIsConnected.test.tsx b/apps/frontend/src/__tests__/useIsConnected.test.tsx similarity index 95% rename from apps/frontend/src/hooks/useIsConnected.test.tsx rename to apps/frontend/src/__tests__/useIsConnected.test.tsx index 387d5115..ca765ce0 100644 --- a/apps/frontend/src/hooks/useIsConnected.test.tsx +++ b/apps/frontend/src/__tests__/useIsConnected.test.tsx @@ -1,160 +1,160 @@ -import { describe, it, expect, vi, beforeEach } from "vitest" -import { renderHook } from "@testing-library/react" -import { Provider } from "react-redux" -import { CONNECTED, CONNECTING, DISCONNECTED, ERROR } from "@common/src/constants" -import useIsConnected from "./useIsConnected" -import { setupTestStore } from "@/test/utils/test-utils" -import { mockConnectionState } from "@/test/utils/mocks" -import { standaloneConnectFulfilled } from "@/state/valkey-features/connection/connectionSlice" - -// Mock react-router -const mockParams = { id: "conn-1" } - -vi.mock("react-router", async () => { - const actual = await vi.importActual("react-router") - return { - ...actual, - useParams: () => mockParams, - } -}) - -describe("useIsConnected", () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it("should return true when status is CONNECTED", () => { - // User should stay on page when connection is active - const store = setupTestStore({ - valkeyConnection: { - connections: { - "conn-1": mockConnectionState({ - status: CONNECTED, - }), - }, - }, - }) - - const { result } = renderHook(() => useIsConnected(), { - wrapper: ({ children }) => {children}, - }) - - expect(result.current).toBe(true) - }) - - it("should return false when status is DISCONNECTED", () => { - const store = setupTestStore({ - valkeyConnection: { - connections: { - "conn-1": mockConnectionState({ - status: DISCONNECTED, - }), - }, - }, - }) - - const { result } = renderHook(() => useIsConnected(), { - wrapper: ({ children }) => {children}, - }) - - expect(result.current).toBe(false) - }) - - it("should return false when status is CONNECTING", () => { - const store = setupTestStore({ - valkeyConnection: { - connections: { - "conn-1": mockConnectionState({ - status: CONNECTING, - }), - }, - }, - }) - - const { result } = renderHook(() => useIsConnected(), { - wrapper: ({ children }) => {children}, - }) - - expect(result.current).toBe(false) - }) - - it("should return false when status is ERROR", () => { - const store = setupTestStore({ - valkeyConnection: { - connections: { - "conn-1": mockConnectionState({ - status: ERROR, - errorMessage: "Connection failed", - }), - }, - }, - }) - - const { result } = renderHook(() => useIsConnected(), { - wrapper: ({ children }) => {children}, - }) - - expect(result.current).toBe(false) - }) - - it("should return false when status is undefined (no connection found)", () => { - const store = setupTestStore({ - valkeyConnection: { - connections: {}, - }, - }) - - const { result } = renderHook(() => useIsConnected(), { - wrapper: ({ children }) => {children}, - }) - - expect(result.current).toBe(false) - }) - - it("should update when Redux state changes", () => { - const store = setupTestStore({ - valkeyConnection: { - connections: { - "conn-1": mockConnectionState({ - status: CONNECTING, - }), - }, - }, - }) - - const { result, rerender } = renderHook(() => useIsConnected(), { - wrapper: ({ children }) => {children}, - }) - - expect(result.current).toBe(false) - - // Update status to CONNECTED using action creator - store.dispatch(standaloneConnectFulfilled({ - connectionId: "conn-1", - connectionDetails: mockConnectionState().connectionDetails, - })) - - rerender() - - expect(result.current).toBe(true) - }) - - it("should handle missing connection gracefully", () => { - const store = setupTestStore({ - valkeyConnection: { - connections: { - "different-conn": mockConnectionState({ - status: CONNECTED, - }), - }, - }, - }) - - const { result } = renderHook(() => useIsConnected(), { - wrapper: ({ children }) => {children}, - }) - - // conn-1 doesn't exist, so status will be undefined - expect(result.current).toBe(false) - }) -}) +import { describe, it, expect, vi, beforeEach } from "vitest" +import { renderHook } from "@testing-library/react" +import { Provider } from "react-redux" +import { CONNECTED, CONNECTING, DISCONNECTED, ERROR } from "@common/src/constants" +import useIsConnected from "../hooks/useIsConnected" +import { setupTestStore } from "@/test/utils/test-utils" +import { mockConnectionState } from "@/test/utils/mocks" +import { standaloneConnectFulfilled } from "@/state/valkey-features/connection/connectionSlice" + +// Mock react-router +const mockParams = { id: "conn-1" } + +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router") + return { + ...actual, + useParams: () => mockParams, + } +}) + +describe("useIsConnected", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should return true when status is CONNECTED", () => { + // User should stay on page when connection is active + const store = setupTestStore({ + valkeyConnection: { + connections: { + "conn-1": mockConnectionState({ + status: CONNECTED, + }), + }, + }, + }) + + const { result } = renderHook(() => useIsConnected(), { + wrapper: ({ children }) => {children}, + }) + + expect(result.current).toBe(true) + }) + + it("should return false when status is DISCONNECTED", () => { + const store = setupTestStore({ + valkeyConnection: { + connections: { + "conn-1": mockConnectionState({ + status: DISCONNECTED, + }), + }, + }, + }) + + const { result } = renderHook(() => useIsConnected(), { + wrapper: ({ children }) => {children}, + }) + + expect(result.current).toBe(false) + }) + + it("should return false when status is CONNECTING", () => { + const store = setupTestStore({ + valkeyConnection: { + connections: { + "conn-1": mockConnectionState({ + status: CONNECTING, + }), + }, + }, + }) + + const { result } = renderHook(() => useIsConnected(), { + wrapper: ({ children }) => {children}, + }) + + expect(result.current).toBe(false) + }) + + it("should return false when status is ERROR", () => { + const store = setupTestStore({ + valkeyConnection: { + connections: { + "conn-1": mockConnectionState({ + status: ERROR, + errorMessage: "Connection failed", + }), + }, + }, + }) + + const { result } = renderHook(() => useIsConnected(), { + wrapper: ({ children }) => {children}, + }) + + expect(result.current).toBe(false) + }) + + it("should return false when status is undefined (no connection found)", () => { + const store = setupTestStore({ + valkeyConnection: { + connections: {}, + }, + }) + + const { result } = renderHook(() => useIsConnected(), { + wrapper: ({ children }) => {children}, + }) + + expect(result.current).toBe(false) + }) + + it("should update when Redux state changes", () => { + const store = setupTestStore({ + valkeyConnection: { + connections: { + "conn-1": mockConnectionState({ + status: CONNECTING, + }), + }, + }, + }) + + const { result, rerender } = renderHook(() => useIsConnected(), { + wrapper: ({ children }) => {children}, + }) + + expect(result.current).toBe(false) + + // Update status to CONNECTED using action creator + store.dispatch(standaloneConnectFulfilled({ + connectionId: "conn-1", + connectionDetails: mockConnectionState().connectionDetails, + })) + + rerender() + + expect(result.current).toBe(true) + }) + + it("should handle missing connection gracefully", () => { + const store = setupTestStore({ + valkeyConnection: { + connections: { + "different-conn": mockConnectionState({ + status: CONNECTED, + }), + }, + }, + }) + + const { result } = renderHook(() => useIsConnected(), { + wrapper: ({ children }) => {children}, + }) + + // conn-1 doesn't exist, so status will be undefined + expect(result.current).toBe(false) + }) +}) diff --git a/apps/frontend/src/hooks/useValkeyConnectionNavigation.test.tsx b/apps/frontend/src/__tests__/useValkeyConnectionNavigation.test.tsx similarity index 95% rename from apps/frontend/src/hooks/useValkeyConnectionNavigation.test.tsx rename to apps/frontend/src/__tests__/useValkeyConnectionNavigation.test.tsx index cebb779b..3bce8273 100644 --- a/apps/frontend/src/hooks/useValkeyConnectionNavigation.test.tsx +++ b/apps/frontend/src/__tests__/useValkeyConnectionNavigation.test.tsx @@ -1,322 +1,322 @@ -import { describe, it, expect, vi, beforeEach } from "vitest" -import { renderHook } from "@testing-library/react" -import { Provider } from "react-redux" -import { BrowserRouter } from "react-router" -import { CONNECTED, CONNECTING, ERROR } from "@common/src/constants" -import { useValkeyConnectionNavigation } from "./useValkeyConnectionNavigation" -import { setupTestStore } from "@/test/utils/test-utils" -import { mockConnectionState } from "@/test/utils/mocks" - -// Mock react-router -const mockNavigate = vi.fn() -let mockLocation = { pathname: "/conn-1/dashboard" } -let mockParams: { id?: string } = { id: "conn-1" } - -vi.mock("react-router", async () => { - const actual = await vi.importActual("react-router") - return { - ...actual, - useNavigate: () => mockNavigate, - useLocation: () => mockLocation, - useParams: () => mockParams, - } -}) - -describe("useValkeyConnectionNavigation", () => { - beforeEach(() => { - vi.clearAllMocks() - sessionStorage.clear() - mockLocation = { pathname: "/conn-1/dashboard" } - mockParams = { id: "conn-1" } - }) - - it("should not navigate if no connection id in params", () => { - mockParams = { id: undefined } - - const store = setupTestStore({ - valkeyConnection: { - connections: {}, - }, - }) - - renderHook(() => useValkeyConnectionNavigation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }) - - expect(mockNavigate).not.toHaveBeenCalled() - }) - - it("should not navigate if connection not found in state", () => { - mockParams = { id: "nonexistent-id" } - - const store = setupTestStore({ - valkeyConnection: { - connections: { - "conn-1": mockConnectionState({ status: CONNECTED }), - }, - }, - }) - - renderHook(() => useValkeyConnectionNavigation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }) - - expect(mockNavigate).not.toHaveBeenCalled() - }) - - it("should not navigate when already on /valkey-reconnect page", () => { - mockLocation = { pathname: "/conn-1/valkey-reconnect" } - - const store = setupTestStore({ - valkeyConnection: { - connections: { - "conn-1": mockConnectionState({ - status: ERROR, - reconnect: { isRetrying: true, currentAttempt: 1, maxRetries: 8 }, - }), - }, - }, - }) - - renderHook(() => useValkeyConnectionNavigation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }) - - expect(mockNavigate).not.toHaveBeenCalled() - }) - - it("should not navigate when on /connect or /settings page", () => { - mockLocation = { pathname: "/connect" } - - const store = setupTestStore({ - valkeyConnection: { - connections: { - "conn-1": mockConnectionState({ - status: ERROR, - reconnect: { isRetrying: true, currentAttempt: 1, maxRetries: 8 }, - }), - }, - }, - }) - - renderHook(() => useValkeyConnectionNavigation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }) - - expect(mockNavigate).not.toHaveBeenCalled() - - // Test /settings - vi.clearAllMocks() - mockLocation = { pathname: "/settings" } - - const { rerender } = renderHook(() => useValkeyConnectionNavigation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }) - - rerender() - expect(mockNavigate).not.toHaveBeenCalled() - }) - - it("should navigate to valkey-reconnect when status is CONNECTING with retry", () => { - mockLocation = { pathname: "/conn-1/keys" } - - const store = setupTestStore({ - valkeyConnection: { - connections: { - "conn-1": mockConnectionState({ - status: CONNECTING, - reconnect: { isRetrying: true, currentAttempt: 1, maxRetries: 8 }, - }), - }, - }, - }) - - renderHook(() => useValkeyConnectionNavigation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }) - - expect(mockNavigate).toHaveBeenCalledWith("/conn-1/valkey-reconnect", { replace: true }) - expect(sessionStorage.getItem("valkey-previous-conn-1")).toBe("/conn-1/keys") - }) - - it("should navigate to valkey-reconnect when status is ERROR with retry", () => { - mockLocation = { pathname: "/conn-1/monitoring" } - - const store = setupTestStore({ - valkeyConnection: { - connections: { - "conn-1": mockConnectionState({ - status: ERROR, - errorMessage: "Connection failed", - reconnect: { isRetrying: true, currentAttempt: 2, maxRetries: 8 }, - }), - }, - }, - }) - - renderHook(() => useValkeyConnectionNavigation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }) - - expect(mockNavigate).toHaveBeenCalledWith("/conn-1/valkey-reconnect", { replace: true }) - expect(sessionStorage.getItem("valkey-previous-conn-1")).toBe("/conn-1/monitoring") - }) - - it("should navigate when status is ERROR with reconnect state but not retrying", () => { - mockLocation = { pathname: "/conn-1/cluster" } - - const store = setupTestStore({ - valkeyConnection: { - connections: { - "conn-1": mockConnectionState({ - status: ERROR, - errorMessage: "Max retries exceeded", - reconnect: { isRetrying: false, currentAttempt: 8, maxRetries: 8 }, - }), - }, - }, - }) - - renderHook(() => useValkeyConnectionNavigation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }) - - expect(mockNavigate).toHaveBeenCalledWith("/conn-1/valkey-reconnect", { replace: true }) - expect(sessionStorage.getItem("valkey-previous-conn-1")).toBe("/conn-1/cluster") - }) - - it("should handle multiple connections independently", () => { - // First connection in error state - mockParams = { id: "conn-1" } - mockLocation = { pathname: "/conn-1/dashboard" } - - const store = setupTestStore({ - valkeyConnection: { - connections: { - "conn-1": mockConnectionState({ - status: ERROR, - reconnect: { isRetrying: true, currentAttempt: 1, maxRetries: 8 }, - }), - "conn-2": mockConnectionState({ - status: CONNECTED, - }), - }, - }, - }) - - renderHook(() => useValkeyConnectionNavigation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }) - - expect(mockNavigate).toHaveBeenCalledWith("/conn-1/valkey-reconnect", { replace: true }) - expect(sessionStorage.getItem("valkey-previous-conn-1")).toBe("/conn-1/dashboard") - - // Second connection should not be affected - vi.clearAllMocks() - mockParams = { id: "conn-2" } - mockLocation = { pathname: "/conn-2/dashboard" } - - renderHook(() => useValkeyConnectionNavigation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }) - - expect(mockNavigate).not.toHaveBeenCalled() - }) - - it("should not navigate if status transitions but no retry state", () => { - mockLocation = { pathname: "/conn-1/keys" } - - const store = setupTestStore({ - valkeyConnection: { - connections: { - "conn-1": mockConnectionState({ - status: ERROR, - errorMessage: "Some error", - reconnect: undefined, // No reconnect state - }), - }, - }, - }) - - renderHook(() => useValkeyConnectionNavigation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }) - - expect(mockNavigate).not.toHaveBeenCalled() - }) - - it("should store correct session storage key per connection", () => { - mockParams = { id: "my-custom-connection-id" } - mockLocation = { pathname: "/my-custom-connection-id/monitoring" } - - const store = setupTestStore({ - valkeyConnection: { - connections: { - "my-custom-connection-id": mockConnectionState({ - status: CONNECTING, - reconnect: { isRetrying: true, currentAttempt: 1, maxRetries: 8 }, - }), - }, - }, - }) - - renderHook(() => useValkeyConnectionNavigation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }) - - expect(mockNavigate).toHaveBeenCalledWith("/my-custom-connection-id/valkey-reconnect", { - replace: true, - }) - expect(sessionStorage.getItem("valkey-previous-my-custom-connection-id")).toBe( - "/my-custom-connection-id/monitoring", - ) - }) -}) +import { describe, it, expect, vi, beforeEach } from "vitest" +import { renderHook } from "@testing-library/react" +import { Provider } from "react-redux" +import { BrowserRouter } from "react-router" +import { CONNECTED, CONNECTING, ERROR } from "@common/src/constants" +import { useValkeyConnectionNavigation } from "../hooks/useValkeyConnectionNavigation" +import { setupTestStore } from "@/test/utils/test-utils" +import { mockConnectionState } from "@/test/utils/mocks" + +// Mock react-router +const mockNavigate = vi.fn() +let mockLocation = { pathname: "/conn-1/dashboard" } +let mockParams: { id?: string } = { id: "conn-1" } + +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router") + return { + ...actual, + useNavigate: () => mockNavigate, + useLocation: () => mockLocation, + useParams: () => mockParams, + } +}) + +describe("useValkeyConnectionNavigation", () => { + beforeEach(() => { + vi.clearAllMocks() + sessionStorage.clear() + mockLocation = { pathname: "/conn-1/dashboard" } + mockParams = { id: "conn-1" } + }) + + it("should not navigate if no connection id in params", () => { + mockParams = { id: undefined } + + const store = setupTestStore({ + valkeyConnection: { + connections: {}, + }, + }) + + renderHook(() => useValkeyConnectionNavigation(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it("should not navigate if connection not found in state", () => { + mockParams = { id: "nonexistent-id" } + + const store = setupTestStore({ + valkeyConnection: { + connections: { + "conn-1": mockConnectionState({ status: CONNECTED }), + }, + }, + }) + + renderHook(() => useValkeyConnectionNavigation(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it("should not navigate when already on /valkey-reconnect page", () => { + mockLocation = { pathname: "/conn-1/valkey-reconnect" } + + const store = setupTestStore({ + valkeyConnection: { + connections: { + "conn-1": mockConnectionState({ + status: ERROR, + reconnect: { isRetrying: true, currentAttempt: 1, maxRetries: 8 }, + }), + }, + }, + }) + + renderHook(() => useValkeyConnectionNavigation(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it("should not navigate when on /connect or /settings page", () => { + mockLocation = { pathname: "/connect" } + + const store = setupTestStore({ + valkeyConnection: { + connections: { + "conn-1": mockConnectionState({ + status: ERROR, + reconnect: { isRetrying: true, currentAttempt: 1, maxRetries: 8 }, + }), + }, + }, + }) + + renderHook(() => useValkeyConnectionNavigation(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + + expect(mockNavigate).not.toHaveBeenCalled() + + // Test /settings + vi.clearAllMocks() + mockLocation = { pathname: "/settings" } + + const { rerender } = renderHook(() => useValkeyConnectionNavigation(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + + rerender() + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it("should navigate to valkey-reconnect when status is CONNECTING with retry", () => { + mockLocation = { pathname: "/conn-1/keys" } + + const store = setupTestStore({ + valkeyConnection: { + connections: { + "conn-1": mockConnectionState({ + status: CONNECTING, + reconnect: { isRetrying: true, currentAttempt: 1, maxRetries: 8 }, + }), + }, + }, + }) + + renderHook(() => useValkeyConnectionNavigation(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + + expect(mockNavigate).toHaveBeenCalledWith("/conn-1/valkey-reconnect", { replace: true }) + expect(sessionStorage.getItem("valkey-previous-conn-1")).toBe("/conn-1/keys") + }) + + it("should navigate to valkey-reconnect when status is ERROR with retry", () => { + mockLocation = { pathname: "/conn-1/monitoring" } + + const store = setupTestStore({ + valkeyConnection: { + connections: { + "conn-1": mockConnectionState({ + status: ERROR, + errorMessage: "Connection failed", + reconnect: { isRetrying: true, currentAttempt: 2, maxRetries: 8 }, + }), + }, + }, + }) + + renderHook(() => useValkeyConnectionNavigation(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + + expect(mockNavigate).toHaveBeenCalledWith("/conn-1/valkey-reconnect", { replace: true }) + expect(sessionStorage.getItem("valkey-previous-conn-1")).toBe("/conn-1/monitoring") + }) + + it("should navigate when status is ERROR with reconnect state but not retrying", () => { + mockLocation = { pathname: "/conn-1/cluster" } + + const store = setupTestStore({ + valkeyConnection: { + connections: { + "conn-1": mockConnectionState({ + status: ERROR, + errorMessage: "Max retries exceeded", + reconnect: { isRetrying: false, currentAttempt: 8, maxRetries: 8 }, + }), + }, + }, + }) + + renderHook(() => useValkeyConnectionNavigation(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + + expect(mockNavigate).toHaveBeenCalledWith("/conn-1/valkey-reconnect", { replace: true }) + expect(sessionStorage.getItem("valkey-previous-conn-1")).toBe("/conn-1/cluster") + }) + + it("should handle multiple connections independently", () => { + // First connection in error state + mockParams = { id: "conn-1" } + mockLocation = { pathname: "/conn-1/dashboard" } + + const store = setupTestStore({ + valkeyConnection: { + connections: { + "conn-1": mockConnectionState({ + status: ERROR, + reconnect: { isRetrying: true, currentAttempt: 1, maxRetries: 8 }, + }), + "conn-2": mockConnectionState({ + status: CONNECTED, + }), + }, + }, + }) + + renderHook(() => useValkeyConnectionNavigation(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + + expect(mockNavigate).toHaveBeenCalledWith("/conn-1/valkey-reconnect", { replace: true }) + expect(sessionStorage.getItem("valkey-previous-conn-1")).toBe("/conn-1/dashboard") + + // Second connection should not be affected + vi.clearAllMocks() + mockParams = { id: "conn-2" } + mockLocation = { pathname: "/conn-2/dashboard" } + + renderHook(() => useValkeyConnectionNavigation(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it("should not navigate if status transitions but no retry state", () => { + mockLocation = { pathname: "/conn-1/keys" } + + const store = setupTestStore({ + valkeyConnection: { + connections: { + "conn-1": mockConnectionState({ + status: ERROR, + errorMessage: "Some error", + reconnect: undefined, // No reconnect state + }), + }, + }, + }) + + renderHook(() => useValkeyConnectionNavigation(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it("should store correct session storage key per connection", () => { + mockParams = { id: "my-custom-connection-id" } + mockLocation = { pathname: "/my-custom-connection-id/monitoring" } + + const store = setupTestStore({ + valkeyConnection: { + connections: { + "my-custom-connection-id": mockConnectionState({ + status: CONNECTING, + reconnect: { isRetrying: true, currentAttempt: 1, maxRetries: 8 }, + }), + }, + }, + }) + + renderHook(() => useValkeyConnectionNavigation(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + + expect(mockNavigate).toHaveBeenCalledWith("/my-custom-connection-id/valkey-reconnect", { + replace: true, + }) + expect(sessionStorage.getItem("valkey-previous-my-custom-connection-id")).toBe( + "/my-custom-connection-id/monitoring", + ) + }) +}) diff --git a/apps/frontend/src/hooks/useWebSocketNavigation.test.tsx b/apps/frontend/src/__tests__/useWebSocketNavigation.test.tsx similarity index 95% rename from apps/frontend/src/hooks/useWebSocketNavigation.test.tsx rename to apps/frontend/src/__tests__/useWebSocketNavigation.test.tsx index f11c67c9..ac516ef2 100644 --- a/apps/frontend/src/hooks/useWebSocketNavigation.test.tsx +++ b/apps/frontend/src/__tests__/useWebSocketNavigation.test.tsx @@ -1,216 +1,216 @@ -import { describe, it, expect, vi, beforeEach } from "vitest" -import { renderHook } from "@testing-library/react" -import { Provider } from "react-redux" -import { BrowserRouter } from "react-router" -import { CONNECTED, CONNECTING, ERROR } from "@common/src/constants" -import { useWebSocketNavigation } from "./useWebSocketNavigation" -import { setupTestStore } from "@/test/utils/test-utils" -import { mockWebSocketState } from "@/test/utils/mocks" - -// Mock react-router -const mockNavigate = vi.fn() -let mockLocation = { pathname: "/dashboard" } - -vi.mock("react-router", async () => { - const actual = await vi.importActual("react-router") - return { - ...actual, - useNavigate: () => mockNavigate, - useLocation: () => mockLocation, - } -}) - -describe("useWebSocketNavigation", () => { - beforeEach(() => { - vi.clearAllMocks() - sessionStorage.clear() - mockLocation = { pathname: "/dashboard" } - }) - - it("should not navigate when already on /reconnect page", () => { - mockLocation = { pathname: "/reconnect" } - - const store = setupTestStore({ - websocket: mockWebSocketState({ - status: ERROR, - reconnect: { isRetrying: true, currentAttempt: 1, maxRetries: 8, nextRetryDelay: 1000 }, - }), - }) - - renderHook(() => useWebSocketNavigation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }) - - expect(mockNavigate).not.toHaveBeenCalled() - }) - - it("should not navigate when on /connect page", () => { - mockLocation = { pathname: "/connect" } - - const store = setupTestStore({ - websocket: mockWebSocketState({ - status: ERROR, - reconnect: { isRetrying: true, currentAttempt: 1, maxRetries: 8, nextRetryDelay: 1000 }, - }), - }) - - renderHook(() => useWebSocketNavigation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }) - - expect(mockNavigate).not.toHaveBeenCalled() - }) - - it("should navigate to /reconnect and store previous location on ERROR without retry", () => { - mockLocation = { pathname: "/dashboard" } - - const store = setupTestStore({ - websocket: mockWebSocketState({ - status: ERROR, - reconnect: { isRetrying: false, currentAttempt: 0, maxRetries: 8, nextRetryDelay: null }, - }), - }) - - renderHook(() => useWebSocketNavigation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }) - - expect(mockNavigate).toHaveBeenCalledWith("/reconnect", { replace: true }) - expect(sessionStorage.getItem("previousLocation")).toBe("/dashboard") - }) - - it("should navigate to /reconnect when connection drops from CONNECTED to CONNECTING with retry", () => { - mockLocation = { pathname: "/keys" } - - // Initial state: CONNECTED - const store = setupTestStore({ - websocket: mockWebSocketState({ - status: CONNECTED, - reconnect: { isRetrying: false, currentAttempt: 0, maxRetries: 8, nextRetryDelay: null }, - }), - }) - - const { rerender } = renderHook(() => useWebSocketNavigation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }) - - // Simulate status change to CONNECTING with retry - store.dispatch({ - type: "wsconnection/reconnectAttempt", - payload: { attempt: 1, maxRetries: 8, nextRetryDelay: 1000 }, - }) - store.dispatch({ type: "wsconnection/connectPending" }) - - rerender() - - expect(mockNavigate).toHaveBeenCalledWith("/reconnect", { replace: true }) - expect(sessionStorage.getItem("previousLocation")).toBe("/keys") - }) - - it("should not navigate if status transitions but isRetrying is false", () => { - mockLocation = { pathname: "/monitoring" } - - const store = setupTestStore({ - websocket: mockWebSocketState({ - status: CONNECTING, - reconnect: { isRetrying: false, currentAttempt: 0, maxRetries: 8, nextRetryDelay: null }, - }), - }) - - renderHook(() => useWebSocketNavigation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }) - - expect(mockNavigate).not.toHaveBeenCalled() - }) - - it("should update previous status ref correctly", () => { - mockLocation = { pathname: "/settings" } - - // Start with CONNECTED - const store = setupTestStore({ - websocket: mockWebSocketState({ - status: CONNECTED, - reconnect: { isRetrying: false, currentAttempt: 0, maxRetries: 8, nextRetryDelay: null }, - }), - }) - - const { rerender } = renderHook(() => useWebSocketNavigation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }) - - // Change to CONNECTING without retry (should not navigate) - store.dispatch({ type: "wsconnection/connectPending" }) - rerender() - - // Should not navigate because wasConnected is true but isRetrying is false - expect(mockNavigate).not.toHaveBeenCalled() - }) - - it("should handle multiple reconnect attempts", () => { - mockLocation = { pathname: "/cluster" } - - const store = setupTestStore({ - websocket: mockWebSocketState({ - status: CONNECTED, - reconnect: { isRetrying: false, currentAttempt: 0, maxRetries: 8, nextRetryDelay: null }, - }), - }) - - const { rerender } = renderHook(() => useWebSocketNavigation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }) - - // First reconnect attempt - store.dispatch({ - type: "wsconnection/reconnectAttempt", - payload: { attempt: 1, maxRetries: 8, nextRetryDelay: 1000 }, - }) - store.dispatch({ type: "wsconnection/connectPending" }) - rerender() - - expect(mockNavigate).toHaveBeenCalledTimes(1) - expect(mockNavigate).toHaveBeenCalledWith("/reconnect", { replace: true }) - - // Navigate is called, now we"re on /reconnect page - mockLocation = { pathname: "/reconnect" } - vi.clearAllMocks() - - // Second reconnect attempt - should not navigate again - store.dispatch({ - type: "wsconnection/reconnectAttempt", - payload: { attempt: 2, maxRetries: 8, nextRetryDelay: 2000 }, - }) - rerender() - - expect(mockNavigate).not.toHaveBeenCalled() - }) -}) +import { describe, it, expect, vi, beforeEach } from "vitest" +import { renderHook } from "@testing-library/react" +import { Provider } from "react-redux" +import { BrowserRouter } from "react-router" +import { CONNECTED, CONNECTING, ERROR } from "@common/src/constants" +import { useWebSocketNavigation } from "../hooks/useWebSocketNavigation" +import { setupTestStore } from "@/test/utils/test-utils" +import { mockWebSocketState } from "@/test/utils/mocks" + +// Mock react-router +const mockNavigate = vi.fn() +let mockLocation = { pathname: "/dashboard" } + +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router") + return { + ...actual, + useNavigate: () => mockNavigate, + useLocation: () => mockLocation, + } +}) + +describe("useWebSocketNavigation", () => { + beforeEach(() => { + vi.clearAllMocks() + sessionStorage.clear() + mockLocation = { pathname: "/dashboard" } + }) + + it("should not navigate when already on /reconnect page", () => { + mockLocation = { pathname: "/reconnect" } + + const store = setupTestStore({ + websocket: mockWebSocketState({ + status: ERROR, + reconnect: { isRetrying: true, currentAttempt: 1, maxRetries: 8, nextRetryDelay: 1000 }, + }), + }) + + renderHook(() => useWebSocketNavigation(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it("should not navigate when on /connect page", () => { + mockLocation = { pathname: "/connect" } + + const store = setupTestStore({ + websocket: mockWebSocketState({ + status: ERROR, + reconnect: { isRetrying: true, currentAttempt: 1, maxRetries: 8, nextRetryDelay: 1000 }, + }), + }) + + renderHook(() => useWebSocketNavigation(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it("should navigate to /reconnect and store previous location on ERROR without retry", () => { + mockLocation = { pathname: "/dashboard" } + + const store = setupTestStore({ + websocket: mockWebSocketState({ + status: ERROR, + reconnect: { isRetrying: false, currentAttempt: 0, maxRetries: 8, nextRetryDelay: null }, + }), + }) + + renderHook(() => useWebSocketNavigation(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + + expect(mockNavigate).toHaveBeenCalledWith("/reconnect", { replace: true }) + expect(sessionStorage.getItem("previousLocation")).toBe("/dashboard") + }) + + it("should navigate to /reconnect when connection drops from CONNECTED to CONNECTING with retry", () => { + mockLocation = { pathname: "/keys" } + + // Initial state: CONNECTED + const store = setupTestStore({ + websocket: mockWebSocketState({ + status: CONNECTED, + reconnect: { isRetrying: false, currentAttempt: 0, maxRetries: 8, nextRetryDelay: null }, + }), + }) + + const { rerender } = renderHook(() => useWebSocketNavigation(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + + // Simulate status change to CONNECTING with retry + store.dispatch({ + type: "wsconnection/reconnectAttempt", + payload: { attempt: 1, maxRetries: 8, nextRetryDelay: 1000 }, + }) + store.dispatch({ type: "wsconnection/connectPending" }) + + rerender() + + expect(mockNavigate).toHaveBeenCalledWith("/reconnect", { replace: true }) + expect(sessionStorage.getItem("previousLocation")).toBe("/keys") + }) + + it("should not navigate if status transitions but isRetrying is false", () => { + mockLocation = { pathname: "/monitoring" } + + const store = setupTestStore({ + websocket: mockWebSocketState({ + status: CONNECTING, + reconnect: { isRetrying: false, currentAttempt: 0, maxRetries: 8, nextRetryDelay: null }, + }), + }) + + renderHook(() => useWebSocketNavigation(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it("should update previous status ref correctly", () => { + mockLocation = { pathname: "/settings" } + + // Start with CONNECTED + const store = setupTestStore({ + websocket: mockWebSocketState({ + status: CONNECTED, + reconnect: { isRetrying: false, currentAttempt: 0, maxRetries: 8, nextRetryDelay: null }, + }), + }) + + const { rerender } = renderHook(() => useWebSocketNavigation(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + + // Change to CONNECTING without retry (should not navigate) + store.dispatch({ type: "wsconnection/connectPending" }) + rerender() + + // Should not navigate because wasConnected is true but isRetrying is false + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it("should handle multiple reconnect attempts", () => { + mockLocation = { pathname: "/cluster" } + + const store = setupTestStore({ + websocket: mockWebSocketState({ + status: CONNECTED, + reconnect: { isRetrying: false, currentAttempt: 0, maxRetries: 8, nextRetryDelay: null }, + }), + }) + + const { rerender } = renderHook(() => useWebSocketNavigation(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + + // First reconnect attempt + store.dispatch({ + type: "wsconnection/reconnectAttempt", + payload: { attempt: 1, maxRetries: 8, nextRetryDelay: 1000 }, + }) + store.dispatch({ type: "wsconnection/connectPending" }) + rerender() + + expect(mockNavigate).toHaveBeenCalledTimes(1) + expect(mockNavigate).toHaveBeenCalledWith("/reconnect", { replace: true }) + + // Navigate is called, now we"re on /reconnect page + mockLocation = { pathname: "/reconnect" } + vi.clearAllMocks() + + // Second reconnect attempt - should not navigate again + store.dispatch({ + type: "wsconnection/reconnectAttempt", + payload: { attempt: 2, maxRetries: 8, nextRetryDelay: 2000 }, + }) + rerender() + + expect(mockNavigate).not.toHaveBeenCalled() + }) +}) diff --git a/apps/frontend/src/__tests__/valkey-command-matching.test.ts b/apps/frontend/src/__tests__/valkey-command-matching.test.ts new file mode 100644 index 00000000..77addc99 --- /dev/null +++ b/apps/frontend/src/__tests__/valkey-command-matching.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest" +import { matchCommands } from "../utils/valkey-command-matching" + +describe("Valkey Command Matching", () => { + it("should return empty array for empty query", () => { + const results = matchCommands("") + expect(results).toEqual([]) + }) + + it("should return empty array for whitespace query", () => { + const results = matchCommands(" ") + expect(results).toEqual([]) + }) + + it("should find exact prefix matches", () => { + const results = matchCommands("GET") + expect(results.length).toBeGreaterThan(0) + expect(results[0].command.name).toBe("GET") + expect(results[0].matchType).toBe("prefix") + }) + + it("should prioritize prefix matches over contains matches", () => { + const results = matchCommands("SET") + const setCommand = results.find((r) => r.command.name === "SET") + const hsetCommand = results.find((r) => r.command.name === "HSET") + + expect(setCommand).toBeDefined() + expect(hsetCommand).toBeDefined() + + if (setCommand && hsetCommand) { + const setIndex = results.indexOf(setCommand) + const hsetIndex = results.indexOf(hsetCommand) + expect(setIndex).toBeLessThan(hsetIndex) + } + }) + + it("should limit results to maxResults parameter", () => { + const results = matchCommands("S", 5) + expect(results.length).toBeLessThanOrEqual(5) + }) + + it("should find contains matches", () => { + const results = matchCommands("PUSH") + expect(results.length).toBeGreaterThan(0) + + const lpushResult = results.find((r) => r.command.name === "LPUSH") + const rpushResult = results.find((r) => r.command.name === "RPUSH") + + expect(lpushResult).toBeDefined() + expect(rpushResult).toBeDefined() + expect(lpushResult?.matchType).toBe("contains") + expect(rpushResult?.matchType).toBe("contains") + }) + + it("should handle case insensitive matching", () => { + const results = matchCommands("get") + expect(results.length).toBeGreaterThan(0) + expect(results[0].command.name).toBe("GET") + }) + + it("should include highlight ranges for matches", () => { + const results = matchCommands("GET") + expect(results[0].highlightRanges).toBeDefined() + expect(results[0].highlightRanges.length).toBeGreaterThan(0) + expect(results[0].highlightRanges[0]).toEqual([0, 3]) + }) +}) diff --git a/apps/frontend/src/components/RequireConnection.tsx b/apps/frontend/src/components/RequireConnection.tsx index 695526a3..9d3a9e01 100644 --- a/apps/frontend/src/components/RequireConnection.tsx +++ b/apps/frontend/src/components/RequireConnection.tsx @@ -1,10 +1,10 @@ -import { Navigate, Outlet } from "react-router" -import useIsConnected from "@/hooks/useIsConnected.ts" - -const RequireConnection = () => { - const isConnected = useIsConnected() - - return isConnected ? : -} - -export default RequireConnection +import { Navigate, Outlet } from "react-router" +import useIsConnected from "@/hooks/useIsConnected.ts" + +const RequireConnection = () => { + const isConnected = useIsConnected() + + return isConnected ? : +} + +export default RequireConnection diff --git a/apps/frontend/src/components/ValkeyReconnect.tsx b/apps/frontend/src/components/ValkeyReconnect.tsx index f90ce33e..82aee0fc 100644 --- a/apps/frontend/src/components/ValkeyReconnect.tsx +++ b/apps/frontend/src/components/ValkeyReconnect.tsx @@ -1,121 +1,121 @@ -import { useEffect } from "react" -import { useDispatch, useSelector } from "react-redux" -import { useNavigate, useParams } from "react-router" -import { CONNECTED, CONNECTING, ERROR } from "@common/src/constants" -import { Loader2, Database, AlertCircle } from "lucide-react" -import RetryProgress from "./ui/retry-progress" -import type { RootState } from "@/store" -import { connectPending } from "@/state/valkey-features/connection/connectionSlice" - -export function ValkeyReconnect() { - const dispatch = useDispatch() - const navigate = useNavigate() - const { id, clusterId } = useParams() - - const connection = useSelector((state: RootState) => - state.valkeyConnection?.connections?.[id!], - ) - - const { status, errorMessage, reconnect } = connection || {} - - useEffect(() => { - // Redirect to dashboard or previous location on successful connection - if (status === CONNECTED) { - const redirectTo = clusterId ? (`${clusterId}/${id}/dashboard`) : sessionStorage.getItem(`valkey-previous-${id}`) || (`/${id}/dashboard`) - sessionStorage.removeItem(`valkey-previous-${id}`) - navigate(redirectTo, { replace: true }) - } - }, [status, navigate, id, clusterId]) - - const handleManualReconnect = () => { - if (!connection) return - - dispatch(connectPending({ - connectionId: id!, - connectionDetails: connection.connectionDetails, - })) - } - - const getNextRetrySeconds = () => { - if (!reconnect?.nextRetryDelay) return 0 - return Math.ceil(reconnect.nextRetryDelay / 1000) - } - - const isExhausted = status === ERROR && (!reconnect || !reconnect.isRetrying) - - return ( -
-
-
- {status === CONNECTING && reconnect?.isRetrying ? ( -
- -
- ) : isExhausted ? ( - - ) : ( -
- -
- )} -
- -
-

- {isExhausted - ? "Valkey Connection Lost" - : "Reconnecting to Valkey..."} -

-

- {connection.connectionDetails.host}:{connection.connectionDetails.port} -

-

- {isExhausted - ? "Unable to connect to the Valkey instance" - : "Attempting to restore connection to Valkey"} -

- {errorMessage && ( -
-
- -

- {errorMessage} -

-
-
- )} -
- - {/* Retry Progress */} - {reconnect?.isRetrying && ( -
- - - {/* Retry Information */} -
- - Attempt {reconnect.currentAttempt} of {reconnect.maxRetries} - - {reconnect.nextRetryDelay && ( - - Next retry in {getNextRetrySeconds()}s - - )} -
-
- )} - - {isExhausted && ( -
- -
- )} -
-
- ) -} +import { useEffect } from "react" +import { useDispatch, useSelector } from "react-redux" +import { useNavigate, useParams } from "react-router" +import { CONNECTED, CONNECTING, ERROR } from "@common/src/constants" +import { Loader2, Database, AlertCircle } from "lucide-react" +import RetryProgress from "./ui/retry-progress" +import type { RootState } from "@/store" +import { connectPending } from "@/state/valkey-features/connection/connectionSlice" + +export function ValkeyReconnect() { + const dispatch = useDispatch() + const navigate = useNavigate() + const { id, clusterId } = useParams() + + const connection = useSelector((state: RootState) => + state.valkeyConnection?.connections?.[id!], + ) + + const { status, errorMessage, reconnect } = connection || {} + + useEffect(() => { + // Redirect to dashboard or previous location on successful connection + if (status === CONNECTED) { + const redirectTo = clusterId ? (`${clusterId}/${id}/dashboard`) : sessionStorage.getItem(`valkey-previous-${id}`) || (`/${id}/dashboard`) + sessionStorage.removeItem(`valkey-previous-${id}`) + navigate(redirectTo, { replace: true }) + } + }, [status, navigate, id, clusterId]) + + const handleManualReconnect = () => { + if (!connection) return + + dispatch(connectPending({ + connectionId: id!, + connectionDetails: connection.connectionDetails, + })) + } + + const getNextRetrySeconds = () => { + if (!reconnect?.nextRetryDelay) return 0 + return Math.ceil(reconnect.nextRetryDelay / 1000) + } + + const isExhausted = status === ERROR && (!reconnect || !reconnect.isRetrying) + + return ( +
+
+
+ {status === CONNECTING && reconnect?.isRetrying ? ( +
+ +
+ ) : isExhausted ? ( + + ) : ( +
+ +
+ )} +
+ +
+

+ {isExhausted + ? "Valkey Connection Lost" + : "Reconnecting to Valkey..."} +

+

+ {connection.connectionDetails.host}:{connection.connectionDetails.port} +

+

+ {isExhausted + ? "Unable to connect to the Valkey instance" + : "Attempting to restore connection to Valkey"} +

+ {errorMessage && ( +
+
+ +

+ {errorMessage} +

+
+
+ )} +
+ + {/* Retry Progress */} + {reconnect?.isRetrying && ( +
+ + + {/* Retry Information */} +
+ + Attempt {reconnect.currentAttempt} of {reconnect.maxRetries} + + {reconnect.nextRetryDelay && ( + + Next retry in {getNextRetrySeconds()}s + + )} +
+
+ )} + + {isExhausted && ( +
+ +
+ )} +
+
+ ) +} diff --git a/apps/frontend/src/components/WebSocketReconnect.tsx b/apps/frontend/src/components/WebSocketReconnect.tsx index 8e80b237..f47e74cc 100644 --- a/apps/frontend/src/components/WebSocketReconnect.tsx +++ b/apps/frontend/src/components/WebSocketReconnect.tsx @@ -1,108 +1,108 @@ -import { useEffect } from "react" -import { useDispatch, useSelector } from "react-redux" -import { useNavigate } from "react-router" -import { CONNECTED, CONNECTING, ERROR } from "@common/src/constants" -import { Loader2, WifiOff, AlertCircle, ServerOff } from "lucide-react" -import RetryProgress from "./ui/retry-progress" -import type { RootState } from "@/store" -import { connectPending } from "@/state/wsconnection/wsConnectionSlice" - -export function WebSocketReconnect() { - const dispatch = useDispatch() - const navigate = useNavigate() - const wsConnection = useSelector((state: RootState) => state.websocket) - const { status, reconnect, errorMessage } = wsConnection - - useEffect(() => { - // redirect to previous location on successful connection - if (status === CONNECTED) { - const redirectTo = sessionStorage.getItem("previousLocation") || "/connect" - sessionStorage.removeItem("previousLocation") - navigate(redirectTo, { replace: true }) - } - }, [status, navigate]) - - const handleManualReconnect = () => { - dispatch(connectPending()) - } - - const getNextRetrySeconds = () => { - if (!reconnect.nextRetryDelay) return 0 - return Math.ceil(reconnect.nextRetryDelay / 1000) - } - - const isExhausted = status === ERROR && !reconnect.isRetrying - - return ( -
-
-
- {status === CONNECTING && reconnect.isRetrying ? ( -
- -
- ) : isExhausted ? ( - - ) : ( -
- -
- )} -
- -
-

- {isExhausted - ? "WebSocket Server Disconnected" - : "Reconnecting to Server..."} -

-

- {isExhausted - ? "Unable to connect to the WebSocket server" - : "Attempting to restore connection to the WebSocket server"} -

- {errorMessage && ( -
-
- -

- {errorMessage} -

-
-
- )} -
- - {/* Retry Progress */} - {reconnect.isRetrying && ( -
- - - {/* Retry Information */} -
- - Attempt {reconnect.currentAttempt} of {reconnect.maxRetries} - - {reconnect.nextRetryDelay && ( - - Next retry in {getNextRetrySeconds()}s - - )} -
-
- )} - - {isExhausted && ( -
- -
- )} -
-
- ) -} +import { useEffect } from "react" +import { useDispatch, useSelector } from "react-redux" +import { useNavigate } from "react-router" +import { CONNECTED, CONNECTING, ERROR } from "@common/src/constants" +import { Loader2, WifiOff, AlertCircle, ServerOff } from "lucide-react" +import RetryProgress from "./ui/retry-progress" +import type { RootState } from "@/store" +import { connectPending } from "@/state/wsconnection/wsConnectionSlice" + +export function WebSocketReconnect() { + const dispatch = useDispatch() + const navigate = useNavigate() + const wsConnection = useSelector((state: RootState) => state.websocket) + const { status, reconnect, errorMessage } = wsConnection + + useEffect(() => { + // redirect to previous location on successful connection + if (status === CONNECTED) { + const redirectTo = sessionStorage.getItem("previousLocation") || "/connect" + sessionStorage.removeItem("previousLocation") + navigate(redirectTo, { replace: true }) + } + }, [status, navigate]) + + const handleManualReconnect = () => { + dispatch(connectPending()) + } + + const getNextRetrySeconds = () => { + if (!reconnect.nextRetryDelay) return 0 + return Math.ceil(reconnect.nextRetryDelay / 1000) + } + + const isExhausted = status === ERROR && !reconnect.isRetrying + + return ( +
+
+
+ {status === CONNECTING && reconnect.isRetrying ? ( +
+ +
+ ) : isExhausted ? ( + + ) : ( +
+ +
+ )} +
+ +
+

+ {isExhausted + ? "WebSocket Server Disconnected" + : "Reconnecting to Server..."} +

+

+ {isExhausted + ? "Unable to connect to the WebSocket server" + : "Attempting to restore connection to the WebSocket server"} +

+ {errorMessage && ( +
+
+ +

+ {errorMessage} +

+
+
+ )} +
+ + {/* Retry Progress */} + {reconnect.isRetrying && ( +
+ + + {/* Retry Information */} +
+ + Attempt {reconnect.currentAttempt} of {reconnect.maxRetries} + + {reconnect.nextRetryDelay && ( + + Next retry in {getNextRetrySeconds()}s + + )} +
+
+ )} + + {isExhausted && ( +
+ +
+ )} +
+
+ ) +} diff --git a/apps/frontend/src/components/cluster-topology/Cluster.tsx b/apps/frontend/src/components/cluster-topology/Cluster.tsx index e044cead..aed71998 100644 --- a/apps/frontend/src/components/cluster-topology/Cluster.tsx +++ b/apps/frontend/src/components/cluster-topology/Cluster.tsx @@ -1,124 +1,124 @@ -import { useState } from "react" -import { useSelector } from "react-redux" -import { Server, CheckCircle2 } from "lucide-react" -import { useParams } from "react-router" -import { CONNECTED } from "@common/src/constants.ts" -import { AppHeader } from "../ui/app-header" -import RouteContainer from "../ui/route-container" -import { StatCard } from "../ui/stat-card" -import { SearchInput } from "../ui/search-input" -import { ClusterNode } from "./cluster-node" -import { Panel } from "../ui/panel" -import type { RootState } from "@/store.ts" -import { selectCluster } from "@/state/valkey-features/cluster/clusterSelectors" - -export function Cluster() { - const { clusterId } = useParams() - const clusterData = useSelector(selectCluster(clusterId!)) - const [searchQuery, setSearchQuery] = useState("") - - const connectionStatuses = useSelector((state: RootState) => - state.valkeyConnection?.connections || {}, - ) - - if (!clusterData.clusterNodes || !clusterData.data) { - return ( -
- } title="Cluster Topology" /> -
-
- No cluster data available -
-
-
- ) - } - - const clusterEntries = Object.entries(clusterData.clusterNodes) - - // cluster stats - const totalNodes = clusterEntries.length - const connectedNodes = clusterEntries.filter(([primaryKey]) => - connectionStatuses[primaryKey]?.status === CONNECTED, - ).length - const totalReplicas = clusterEntries.reduce((sum, [, primary]) => - sum + primary.replicas.length, 0, - ) - const totalClusterNodes = totalNodes + totalReplicas - - // filtering nodes based on search query - const filteredEntries = clusterEntries.filter(([primaryKey, primary]) => { - if (!searchQuery) return true - - // check primary node - if (clusterData.searchableText[primaryKey]?.includes(searchQuery)) { - return true - } - - // check replicas - return primary.replicas.some((replica) => { - const replicaKey = `${replica.host}:${replica.port}` - return clusterData.searchableText[replicaKey]?.includes(searchQuery) - }) - }) - - return ( - - } title="Cluster Topology" /> - {/* Cluster Stats */} -
- } - label="Total Nodes" - value={totalClusterNodes} - /> - } - label="Primary Nodes" - value={totalNodes} - /> - } - label="Replicas" - value={totalReplicas} - /> - } - label="Connected" - value={connectedNodes} - /> -
- - {/* Search */} -
- setSearchQuery(e.target.value.toLowerCase())} - placeholder="Search nodes by name, host, or port..." - value={searchQuery} - /> -
- - {/* Cluster Topology List */} - - {filteredEntries.length === 0 ? ( -
- No nodes found matching "{searchQuery}" -
- ) : ( - filteredEntries.map(([primaryKey, primary]) => { - const primaryData = clusterData.data[primaryKey] - return ( - - ) - }) - )} -
-
- ) -} +import { useState } from "react" +import { useSelector } from "react-redux" +import { Server, CheckCircle2 } from "lucide-react" +import { useParams } from "react-router" +import { CONNECTED } from "@common/src/constants.ts" +import { AppHeader } from "../ui/app-header" +import RouteContainer from "../ui/route-container" +import { StatCard } from "../ui/stat-card" +import { SearchInput } from "../ui/search-input" +import { ClusterNode } from "./cluster-node" +import { Panel } from "../ui/panel" +import type { RootState } from "@/store.ts" +import { selectCluster } from "@/state/valkey-features/cluster/clusterSelectors" + +export function Cluster() { + const { clusterId } = useParams() + const clusterData = useSelector(selectCluster(clusterId!)) + const [searchQuery, setSearchQuery] = useState("") + + const connectionStatuses = useSelector((state: RootState) => + state.valkeyConnection?.connections || {}, + ) + + if (!clusterData.clusterNodes || !clusterData.data) { + return ( +
+ } title="Cluster Topology" /> +
+
+ No cluster data available +
+
+
+ ) + } + + const clusterEntries = Object.entries(clusterData.clusterNodes) + + // cluster stats + const totalNodes = clusterEntries.length + const connectedNodes = clusterEntries.filter(([primaryKey]) => + connectionStatuses[primaryKey]?.status === CONNECTED, + ).length + const totalReplicas = clusterEntries.reduce((sum, [, primary]) => + sum + primary.replicas.length, 0, + ) + const totalClusterNodes = totalNodes + totalReplicas + + // filtering nodes based on search query + const filteredEntries = clusterEntries.filter(([primaryKey, primary]) => { + if (!searchQuery) return true + + // check primary node + if (clusterData.searchableText[primaryKey]?.includes(searchQuery)) { + return true + } + + // check replicas + return primary.replicas.some((replica) => { + const replicaKey = `${replica.host}:${replica.port}` + return clusterData.searchableText[replicaKey]?.includes(searchQuery) + }) + }) + + return ( + + } title="Cluster Topology" /> + {/* Cluster Stats */} +
+ } + label="Total Nodes" + value={totalClusterNodes} + /> + } + label="Primary Nodes" + value={totalNodes} + /> + } + label="Replicas" + value={totalReplicas} + /> + } + label="Connected" + value={connectedNodes} + /> +
+ + {/* Search */} +
+ setSearchQuery(e.target.value.toLowerCase())} + placeholder="Search nodes by name, host, or port..." + value={searchQuery} + /> +
+ + {/* Cluster Topology List */} + + {filteredEntries.length === 0 ? ( +
+ No nodes found matching "{searchQuery}" +
+ ) : ( + filteredEntries.map(([primaryKey, primary]) => { + const primaryData = clusterData.data[primaryKey] + return ( + + ) + }) + )} +
+
+ ) +} diff --git a/apps/frontend/src/components/cluster-topology/cluster-node.tsx b/apps/frontend/src/components/cluster-topology/cluster-node.tsx index 6ab9a1a0..673b9a1c 100644 --- a/apps/frontend/src/components/cluster-topology/cluster-node.tsx +++ b/apps/frontend/src/components/cluster-topology/cluster-node.tsx @@ -1,162 +1,162 @@ -import { LayoutDashboard, Terminal, PowerIcon, Server, MemoryStick, Users } from "lucide-react" -import { useNavigate } from "react-router" -import { useSelector } from "react-redux" -import { CONNECTED, MAX_CONNECTIONS } from "@common/src/constants.ts" -import { TooltipProvider } from "@radix-ui/react-tooltip" -import { Badge } from "../ui/badge" -import { CustomTooltip } from "../ui/tooltip" -import { Button } from "../ui/button" -import { Typography } from "../ui/typography" -import type { RootState } from "@/store.ts" -import type { PrimaryNode, ParsedNodeInfo } from "@/state/valkey-features/cluster/clusterSlice" -import { connectPending, type ConnectionDetails } from "@/state/valkey-features/connection/connectionSlice.ts" -import { useAppDispatch } from "@/hooks/hooks" -import { selectIsAtConnectionLimit } from "@/state/valkey-features/connection/connectionSelectors" -import { cn } from "@/lib/utils" - -interface ClusterNodeProps { - primaryKey: string - primary: PrimaryNode - primaryData: ParsedNodeInfo - clusterId: string -} - -export function ClusterNode({ - primaryKey, - primary, - primaryData, - clusterId, -}: ClusterNodeProps) { - const navigate = useNavigate() - const dispatch = useAppDispatch() - - const connectionId = primaryKey - const connectionStatus = useSelector((state: RootState) => - state.valkeyConnection?.connections?.[connectionId]?.status, - ) - const isConnected = connectionStatus === CONNECTED - const isDisabled = useSelector(selectIsAtConnectionLimit) - - const handleNodeConnect = () => { - if (!isConnected) { - const connectionDetails: ConnectionDetails = { - host: primary.host, - port: primary.port.toString(), - ...(primary.username && primary.password && { - username: primary.username, - password: primary.password, - }), - tls: primary.tls, - verifyTlsCertificate: primary.verifyTlsCertificate, - ...(primary.caCertPath && { - caCertPath: primary.caCertPath, - }), - } - dispatch(connectPending({ - connectionId, - connectionDetails, - })) - } - } - - const NodeDetails = ({ nodeData }: { nodeData: ParsedNodeInfo }) => ( -
-
- - {nodeData?.used_memory_human ?? "N/A"} -
-
- - {nodeData?.connected_clients ?? "N/A"} -
-
- ) - - return ( -
- -
-
- {/* Primary Node Section */} -
- -
-
- {primaryData?.server_name || primaryKey} - - PRIMARY - -
- {`${primary.host}:${primary.port}`} - -
-
- - {/* Divider */} - {primary.replicas.length > 0 && ( -
- )} - - {/* Replicas Section */} - {primary.replicas.length > 0 && ( -
- - REPLICA{primary.replicas.length > 1 ? "S" : ""} - - {primary.replicas.map((replica) => { - const replicaKey = `${replica.host}:${replica.port}` - return ( -
- - {replicaKey} -
- ) - })} -
- )} - - {/* Actions */} -
- - - - - - - - - -
-
-
- -
- ) -} +import { LayoutDashboard, Terminal, PowerIcon, Server, MemoryStick, Users } from "lucide-react" +import { useNavigate } from "react-router" +import { useSelector } from "react-redux" +import { CONNECTED, MAX_CONNECTIONS } from "@common/src/constants.ts" +import { TooltipProvider } from "@radix-ui/react-tooltip" +import { Badge } from "../ui/badge" +import { CustomTooltip } from "../ui/tooltip" +import { Button } from "../ui/button" +import { Typography } from "../ui/typography" +import type { RootState } from "@/store.ts" +import type { PrimaryNode, ParsedNodeInfo } from "@/state/valkey-features/cluster/clusterSlice" +import { connectPending, type ConnectionDetails } from "@/state/valkey-features/connection/connectionSlice.ts" +import { useAppDispatch } from "@/hooks/hooks" +import { selectIsAtConnectionLimit } from "@/state/valkey-features/connection/connectionSelectors" +import { cn } from "@/lib/utils" + +interface ClusterNodeProps { + primaryKey: string + primary: PrimaryNode + primaryData: ParsedNodeInfo + clusterId: string +} + +export function ClusterNode({ + primaryKey, + primary, + primaryData, + clusterId, +}: ClusterNodeProps) { + const navigate = useNavigate() + const dispatch = useAppDispatch() + + const connectionId = primaryKey + const connectionStatus = useSelector((state: RootState) => + state.valkeyConnection?.connections?.[connectionId]?.status, + ) + const isConnected = connectionStatus === CONNECTED + const isDisabled = useSelector(selectIsAtConnectionLimit) + + const handleNodeConnect = () => { + if (!isConnected) { + const connectionDetails: ConnectionDetails = { + host: primary.host, + port: primary.port.toString(), + ...(primary.username && primary.password && { + username: primary.username, + password: primary.password, + }), + tls: primary.tls, + verifyTlsCertificate: primary.verifyTlsCertificate, + ...(primary.caCertPath && { + caCertPath: primary.caCertPath, + }), + } + dispatch(connectPending({ + connectionId, + connectionDetails, + })) + } + } + + const NodeDetails = ({ nodeData }: { nodeData: ParsedNodeInfo }) => ( +
+
+ + {nodeData?.used_memory_human ?? "N/A"} +
+
+ + {nodeData?.connected_clients ?? "N/A"} +
+
+ ) + + return ( +
+ +
+
+ {/* Primary Node Section */} +
+ +
+
+ {primaryData?.server_name || primaryKey} + + PRIMARY + +
+ {`${primary.host}:${primary.port}`} + +
+
+ + {/* Divider */} + {primary.replicas.length > 0 && ( +
+ )} + + {/* Replicas Section */} + {primary.replicas.length > 0 && ( +
+ + REPLICA{primary.replicas.length > 1 ? "S" : ""} + + {primary.replicas.map((replica) => { + const replicaKey = `${replica.host}:${replica.port}` + return ( +
+ + {replicaKey} +
+ ) + })} +
+ )} + + {/* Actions */} +
+ + + + + + + + + +
+
+
+ +
+ ) +} diff --git a/apps/frontend/src/components/connection/ClusterConnectionGroup.tsx b/apps/frontend/src/components/connection/ClusterConnectionGroup.tsx index 80d32ef2..e1177155 100644 --- a/apps/frontend/src/components/connection/ClusterConnectionGroup.tsx +++ b/apps/frontend/src/components/connection/ClusterConnectionGroup.tsx @@ -1,249 +1,249 @@ -import * as R from "ramda" -import { useState, useMemo, useEffect } from "react" -import { - ChevronDown, - ChevronRight, - Network, - PencilIcon, - CheckIcon, - XIcon, - Plug -} from "lucide-react" -import { Link } from "react-router" -import { CONNECTED } from "@common/src/constants.ts" -import { ConnectionEntry } from "./ConnectionEntry.tsx" -import { Input } from "../ui/input.tsx" -import { - updateConnectionDetails, - type ConnectionState, - connectPending -} from "@/state/valkey-features/connection/connectionSlice" -import { useAppDispatch } from "@/hooks/hooks.ts" -import { Button } from "@/components/ui/button.tsx" -import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip.tsx" -import { Typography } from "@/components/ui/typography.tsx" - -interface ClusterConnectionGroupProps { - clusterId: string - connections: Array<{ connectionId: string; connection: ConnectionState }> - onEdit?: (connectionId: string) => void -} - -// we'll use this function to find the most recent cluster node opened — to reconnect without exploding the dropdown -const getLatestTimestamp = R.pipe( - R.pathOr([], ["connection", "connectionHistory"]), - R.pluck("timestamp") as unknown as (xs: Array<{ timestamp: number }>) => number[], - R.reduce(R.max, -Infinity), -) - -// storage key for persisting open/closed state of cluster groups -const getStorageKey = (clusterId: string) => `cluster-group-open-${clusterId}` - -export const ClusterConnectionGroup = ({ clusterId, connections, onEdit }: ClusterConnectionGroupProps) => { - const dispatch = useAppDispatch() - const [isOpen, setIsOpen] = useState(() => { - const stored = localStorage.getItem(getStorageKey(clusterId)) - return stored ? JSON.parse(stored) : false - }) - const [isEditing, setIsEditing] = useState(false) - const [editedAlias, setEditedAlias] = useState("") - - useEffect(() => { - // store the open/closed state of the cluster group in localStorage - // we want to persist this state when connection page reloads or user navigates away - localStorage.setItem(getStorageKey(clusterId), JSON.stringify(isOpen)) - }, [isOpen, clusterId]) - - const connected = connections.filter(({ connection }) => connection.status === CONNECTED) - const connectedCount = connected.length - const hasConnectedInstance = connectedCount > 0 - const firstConnectedConnection = connected[0] - - // capture cluster alias from the first node's alias - const firstNode = connections[0] - const firstNodeAlias = firstNode?.connection.connectionDetails.alias - - const lastOpenedNode = useMemo( - () => R.reduce(R.maxBy(getLatestTimestamp), null, connections), - [connections], - ) - - const handleEdit = () => { - setEditedAlias(firstNodeAlias || "") - setIsEditing(true) - } - - const handleSave = () => { - if (firstNode) { - dispatch(updateConnectionDetails({ - connectionId: firstNode.connectionId, - alias: editedAlias.trim() || undefined, - })) - } - setIsEditing(false) - } - - const handleCancel = () => { - setEditedAlias(firstNodeAlias || "") - setIsEditing(false) - } - - const handleConnectLatest = () => lastOpenedNode && dispatch(connectPending({ - connectionDetails: lastOpenedNode.connection.connectionDetails, - connectionId: lastOpenedNode.connectionId, - preservedHistory: lastOpenedNode.connection.connectionHistory, - })) - - return ( -
- {/* cluster head */} -
-
- - -
-
- -
-
- {isEditing ? ( -
- setEditedAlias(e.target.value)} - placeholder={clusterId} - style={{ width: `${Math.max(Math.min((editedAlias || clusterId).length * 8 + 20, 250), 100)}px` }} - value={editedAlias} - /> - - -
- ) : ( -
- {hasConnectedInstance ? ( - - -
- - - {firstNodeAlias || clusterId} - - -
-
- - Click to view cluster topology - -
- ) : ( -
- setIsOpen(!isOpen)} - title={firstNodeAlias || clusterId} - variant="code" - > - {firstNodeAlias || clusterId} - -
- )} - -
- )} - - {connections.length} instance{connections.length !== 1 ? "s" : ""} - {hasConnectedInstance && ( - - {connectedCount} connected - - )} - -
-
-
- -
- { - !hasConnectedInstance && lastOpenedNode ? ( - - - - - - Connect to the most recent node in this cluster - - - ) : null - } -
-
- - {isOpen && ( -
-
- {connections.map(({ connectionId, connection }) => ( - - ))} -
-
- )} -
- ) -} +import * as R from "ramda" +import { useState, useMemo, useEffect } from "react" +import { + ChevronDown, + ChevronRight, + Network, + PencilIcon, + CheckIcon, + XIcon, + Plug +} from "lucide-react" +import { Link } from "react-router" +import { CONNECTED } from "@common/src/constants.ts" +import { ConnectionEntry } from "./ConnectionEntry.tsx" +import { Input } from "../ui/input.tsx" +import { + updateConnectionDetails, + type ConnectionState, + connectPending +} from "@/state/valkey-features/connection/connectionSlice" +import { useAppDispatch } from "@/hooks/hooks.ts" +import { Button } from "@/components/ui/button.tsx" +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip.tsx" +import { Typography } from "@/components/ui/typography.tsx" + +interface ClusterConnectionGroupProps { + clusterId: string + connections: Array<{ connectionId: string; connection: ConnectionState }> + onEdit?: (connectionId: string) => void +} + +// we'll use this function to find the most recent cluster node opened — to reconnect without exploding the dropdown +const getLatestTimestamp = R.pipe( + R.pathOr([], ["connection", "connectionHistory"]), + R.pluck("timestamp") as unknown as (xs: Array<{ timestamp: number }>) => number[], + R.reduce(R.max, -Infinity), +) + +// storage key for persisting open/closed state of cluster groups +const getStorageKey = (clusterId: string) => `cluster-group-open-${clusterId}` + +export const ClusterConnectionGroup = ({ clusterId, connections, onEdit }: ClusterConnectionGroupProps) => { + const dispatch = useAppDispatch() + const [isOpen, setIsOpen] = useState(() => { + const stored = localStorage.getItem(getStorageKey(clusterId)) + return stored ? JSON.parse(stored) : false + }) + const [isEditing, setIsEditing] = useState(false) + const [editedAlias, setEditedAlias] = useState("") + + useEffect(() => { + // store the open/closed state of the cluster group in localStorage + // we want to persist this state when connection page reloads or user navigates away + localStorage.setItem(getStorageKey(clusterId), JSON.stringify(isOpen)) + }, [isOpen, clusterId]) + + const connected = connections.filter(({ connection }) => connection.status === CONNECTED) + const connectedCount = connected.length + const hasConnectedInstance = connectedCount > 0 + const firstConnectedConnection = connected[0] + + // capture cluster alias from the first node's alias + const firstNode = connections[0] + const firstNodeAlias = firstNode?.connection.connectionDetails.alias + + const lastOpenedNode = useMemo( + () => R.reduce(R.maxBy(getLatestTimestamp), null, connections), + [connections], + ) + + const handleEdit = () => { + setEditedAlias(firstNodeAlias || "") + setIsEditing(true) + } + + const handleSave = () => { + if (firstNode) { + dispatch(updateConnectionDetails({ + connectionId: firstNode.connectionId, + alias: editedAlias.trim() || undefined, + })) + } + setIsEditing(false) + } + + const handleCancel = () => { + setEditedAlias(firstNodeAlias || "") + setIsEditing(false) + } + + const handleConnectLatest = () => lastOpenedNode && dispatch(connectPending({ + connectionDetails: lastOpenedNode.connection.connectionDetails, + connectionId: lastOpenedNode.connectionId, + preservedHistory: lastOpenedNode.connection.connectionHistory, + })) + + return ( +
+ {/* cluster head */} +
+
+ + +
+
+ +
+
+ {isEditing ? ( +
+ setEditedAlias(e.target.value)} + placeholder={clusterId} + style={{ width: `${Math.max(Math.min((editedAlias || clusterId).length * 8 + 20, 250), 100)}px` }} + value={editedAlias} + /> + + +
+ ) : ( +
+ {hasConnectedInstance ? ( + + +
+ + + {firstNodeAlias || clusterId} + + +
+
+ + Click to view cluster topology + +
+ ) : ( +
+ setIsOpen(!isOpen)} + title={firstNodeAlias || clusterId} + variant="code" + > + {firstNodeAlias || clusterId} + +
+ )} + +
+ )} + + {connections.length} instance{connections.length !== 1 ? "s" : ""} + {hasConnectedInstance && ( + + {connectedCount} connected + + )} + +
+
+
+ +
+ { + !hasConnectedInstance && lastOpenedNode ? ( + + + + + + Connect to the most recent node in this cluster + + + ) : null + } +
+
+ + {isOpen && ( +
+
+ {connections.map(({ connectionId, connection }) => ( + + ))} +
+
+ )} +
+ ) +} diff --git a/apps/frontend/src/components/connection/Connection.tsx b/apps/frontend/src/components/connection/Connection.tsx index ad2691c5..48423846 100644 --- a/apps/frontend/src/components/connection/Connection.tsx +++ b/apps/frontend/src/components/connection/Connection.tsx @@ -1,132 +1,132 @@ -import { useState } from "react" -import { useSelector } from "react-redux" -import { HousePlug } from "lucide-react" -import ConnectionForm from "../ui/connection-form.tsx" -import EditForm from "../ui/edit-form.tsx" -import RouteContainer from "../ui/route-container.tsx" -import { Button } from "../ui/button.tsx" -import { EmptyState } from "../ui/empty-state.tsx" -import { Typography } from "../ui/typography.tsx" -import type { ConnectionState } from "@/state/valkey-features/connection/connectionSlice.ts" -import { selectConnections } from "@/state/valkey-features/connection/connectionSelectors.ts" -import { ConnectionEntry } from "@/components/connection/ConnectionEntry.tsx" -import { ClusterConnectionGroup } from "@/components/connection/ClusterConnectionGroup.tsx" - -export function Connection() { - const [showConnectionForm, setShowConnectionForm] = useState(false) - const [showEditForm, setShowEditForm] = useState(false) - const [editingConnectionId, setEditingConnectionId] = useState(undefined) - const connections = useSelector(selectConnections) - - const handleEditConnection = (connectionId: string) => { - setEditingConnectionId(connectionId) - setShowEditForm(true) - } - - const handleCloseEditForm = () => { - setShowEditForm(false) - setEditingConnectionId(undefined) - } - - // filter based on connections that connected at least once (have history) then sort by history length - const connectionsWithHistory = Object.entries(connections) - .filter(([, connection]) => (connection.connectionHistory ?? []).length > 0) - .sort(([, a], [, b]) => - (b.connectionHistory?.length ?? 0) - (a.connectionHistory?.length ?? 0), - ) - - // grouping connections - const { clusterGroups, standaloneConnections } = connectionsWithHistory.reduce<{ - clusterGroups: Record> - standaloneConnections: Array<{ connectionId: string; connection: ConnectionState }> - }>( - (acc, [connectionId, connection]) => { - const clusterId = connection.connectionDetails.clusterId - if (clusterId) - (acc.clusterGroups[clusterId] ??= []).push({ connectionId, connection }) - else - acc.standaloneConnections.push({ connectionId, connection }) - return acc - }, - { clusterGroups: {}, standaloneConnections: [] }, - ) - - const hasClusterGroups = Object.keys(clusterGroups).length > 0 - const hasStandaloneConnections = standaloneConnections.length > 0 - const hasConnectionsWithHistory = connectionsWithHistory.length > 0 - - return ( - - {/* top header */} -
- - Connections - - {hasConnectionsWithHistory && ( - - )} -
- - {showConnectionForm && setShowConnectionForm(false)} />} - {showEditForm && } - - {!hasConnectionsWithHistory ? ( - setShowConnectionForm(!showConnectionForm)} - size="sm" - variant={"default"} - > - + Add Connection - - } - description="Click '+ Add Connection' button to connect to a Valkey instance or cluster." - title="You Have No Connections!" - /> - ) : ( -
- {/* for clusters */} - {hasClusterGroups && ( -
- Clusters -
- {Object.entries(clusterGroups).map(([clusterId, clusterConnections]) => ( - - ))} -
-
- )} - - {/* for standalone instances */} - {hasStandaloneConnections && ( -
- Instances -
- {standaloneConnections.map(({ connectionId, connection }) => ( - - ))} -
-
- )} -
- )} -
- ) -} +import { useState } from "react" +import { useSelector } from "react-redux" +import { HousePlug } from "lucide-react" +import ConnectionForm from "../ui/connection-form.tsx" +import EditForm from "../ui/edit-form.tsx" +import RouteContainer from "../ui/route-container.tsx" +import { Button } from "../ui/button.tsx" +import { EmptyState } from "../ui/empty-state.tsx" +import { Typography } from "../ui/typography.tsx" +import type { ConnectionState } from "@/state/valkey-features/connection/connectionSlice.ts" +import { selectConnections } from "@/state/valkey-features/connection/connectionSelectors.ts" +import { ConnectionEntry } from "@/components/connection/ConnectionEntry.tsx" +import { ClusterConnectionGroup } from "@/components/connection/ClusterConnectionGroup.tsx" + +export function Connection() { + const [showConnectionForm, setShowConnectionForm] = useState(false) + const [showEditForm, setShowEditForm] = useState(false) + const [editingConnectionId, setEditingConnectionId] = useState(undefined) + const connections = useSelector(selectConnections) + + const handleEditConnection = (connectionId: string) => { + setEditingConnectionId(connectionId) + setShowEditForm(true) + } + + const handleCloseEditForm = () => { + setShowEditForm(false) + setEditingConnectionId(undefined) + } + + // filter based on connections that connected at least once (have history) then sort by history length + const connectionsWithHistory = Object.entries(connections) + .filter(([, connection]) => (connection.connectionHistory ?? []).length > 0) + .sort(([, a], [, b]) => + (b.connectionHistory?.length ?? 0) - (a.connectionHistory?.length ?? 0), + ) + + // grouping connections + const { clusterGroups, standaloneConnections } = connectionsWithHistory.reduce<{ + clusterGroups: Record> + standaloneConnections: Array<{ connectionId: string; connection: ConnectionState }> + }>( + (acc, [connectionId, connection]) => { + const clusterId = connection.connectionDetails.clusterId + if (clusterId) + (acc.clusterGroups[clusterId] ??= []).push({ connectionId, connection }) + else + acc.standaloneConnections.push({ connectionId, connection }) + return acc + }, + { clusterGroups: {}, standaloneConnections: [] }, + ) + + const hasClusterGroups = Object.keys(clusterGroups).length > 0 + const hasStandaloneConnections = standaloneConnections.length > 0 + const hasConnectionsWithHistory = connectionsWithHistory.length > 0 + + return ( + + {/* top header */} +
+ + Connections + + {hasConnectionsWithHistory && ( + + )} +
+ + {showConnectionForm && setShowConnectionForm(false)} />} + {showEditForm && } + + {!hasConnectionsWithHistory ? ( + setShowConnectionForm(!showConnectionForm)} + size="sm" + variant={"default"} + > + + Add Connection + + } + description="Click '+ Add Connection' button to connect to a Valkey instance or cluster." + title="You Have No Connections!" + /> + ) : ( +
+ {/* for clusters */} + {hasClusterGroups && ( +
+ Clusters +
+ {Object.entries(clusterGroups).map(([clusterId, clusterConnections]) => ( + + ))} +
+
+ )} + + {/* for standalone instances */} + {hasStandaloneConnections && ( +
+ Instances +
+ {standaloneConnections.map(({ connectionId, connection }) => ( + + ))} +
+
+ )} +
+ )} +
+ ) +} diff --git a/apps/frontend/src/components/connection/ConnectionEntry.tsx b/apps/frontend/src/components/connection/ConnectionEntry.tsx index f43ec366..5c17fa06 100644 --- a/apps/frontend/src/components/connection/ConnectionEntry.tsx +++ b/apps/frontend/src/components/connection/ConnectionEntry.tsx @@ -1,161 +1,161 @@ -import { CONNECTED, ERROR, CONNECTING } from "@common/src/constants.ts" -import { CircleChevronRight, Server } from "lucide-react" -import { Link } from "react-router" -import { - type ConnectionState, - connectPending, - closeConnection, - deleteConnection -} from "@/state/valkey-features/connection/connectionSlice" -import { Button } from "@/components/ui/button.tsx" -import { ConnectionStatusBadge } from "@/components/ui/connection-status-badge" -import { ConnectionActionButtons } from "@/components/ui/connection-action-buttons.tsx" -import { Typography } from "@/components/ui/typography.tsx" -import { cn } from "@/lib/utils.ts" -import history from "@/history.ts" -import { useAppDispatch } from "@/hooks/hooks.ts" - -interface ConnectionEntryProps { - connectionId: string - connection: ConnectionState - clusterId?: string - hideOpenButton?: boolean - isNested?: boolean - onEdit?: (connectionId: string) => void -} - -export const ConnectionEntry = ({ - connectionId, - connection, - clusterId, - hideOpenButton = false, - isNested = false, - onEdit, -}: ConnectionEntryProps) => { - const dispatch = useAppDispatch() - - const handleDisconnect = () => dispatch(closeConnection({ connectionId })) - const handleConnect = () => dispatch(connectPending({ - connectionDetails: connection.connectionDetails, - connectionId, - preservedHistory: connection.connectionHistory, - })) - const handleDelete = () => dispatch(deleteConnection({ connectionId })) - - const handleEdit = () => { - onEdit?.(connectionId) - } - - const isConnected = connection.status === CONNECTED - const isConnecting = connection.status === CONNECTING - const isError = connection.status === ERROR - const label = connection.connectionDetails.username - ? `${connection.connectionDetails.username}@${connection.connectionDetails.host}:${connection.connectionDetails.port}` - : `${connection.connectionDetails.host}:${connection.connectionDetails.port}` - const aliasLabel = connection.connectionDetails.alias || label - - const lastConnectionTime = connection.connectionHistory?.at(-1) ?? null - - const statusType = isConnected ? "connected" : isError ? "error" : "disconnected" - - // for cluster instances - if (isNested) { - return ( -
-
-
-
- - - - -
{lastConnectionTime && lastConnectionTime.event === CONNECTED && ( - - Last connected: {new Date(lastConnectionTime.timestamp).toLocaleString()} - - )}
- - {/* action buttons */} - -
-
- ) - } - - // for standalone instances - return ( -
-
-
-
- -
- -
- - - - {connection.connectionDetails.alias && ( - - ({label}) - )} -
- - {lastConnectionTime && lastConnectionTime.event === CONNECTED && ( - - Last connected: {new Date(lastConnectionTime.timestamp).toLocaleString()} - - )} -
-
-
- - {/* action buttons */} -
- {isConnected && !hideOpenButton && ( - - )} - -
-
-
- ) -} +import { CONNECTED, ERROR, CONNECTING } from "@common/src/constants.ts" +import { CircleChevronRight, Server } from "lucide-react" +import { Link } from "react-router" +import { + type ConnectionState, + connectPending, + closeConnection, + deleteConnection +} from "@/state/valkey-features/connection/connectionSlice" +import { Button } from "@/components/ui/button.tsx" +import { ConnectionStatusBadge } from "@/components/ui/connection-status-badge" +import { ConnectionActionButtons } from "@/components/ui/connection-action-buttons.tsx" +import { Typography } from "@/components/ui/typography.tsx" +import { cn } from "@/lib/utils.ts" +import history from "@/history.ts" +import { useAppDispatch } from "@/hooks/hooks.ts" + +interface ConnectionEntryProps { + connectionId: string + connection: ConnectionState + clusterId?: string + hideOpenButton?: boolean + isNested?: boolean + onEdit?: (connectionId: string) => void +} + +export const ConnectionEntry = ({ + connectionId, + connection, + clusterId, + hideOpenButton = false, + isNested = false, + onEdit, +}: ConnectionEntryProps) => { + const dispatch = useAppDispatch() + + const handleDisconnect = () => dispatch(closeConnection({ connectionId })) + const handleConnect = () => dispatch(connectPending({ + connectionDetails: connection.connectionDetails, + connectionId, + preservedHistory: connection.connectionHistory, + })) + const handleDelete = () => dispatch(deleteConnection({ connectionId })) + + const handleEdit = () => { + onEdit?.(connectionId) + } + + const isConnected = connection.status === CONNECTED + const isConnecting = connection.status === CONNECTING + const isError = connection.status === ERROR + const label = connection.connectionDetails.username + ? `${connection.connectionDetails.username}@${connection.connectionDetails.host}:${connection.connectionDetails.port}` + : `${connection.connectionDetails.host}:${connection.connectionDetails.port}` + const aliasLabel = connection.connectionDetails.alias || label + + const lastConnectionTime = connection.connectionHistory?.at(-1) ?? null + + const statusType = isConnected ? "connected" : isError ? "error" : "disconnected" + + // for cluster instances + if (isNested) { + return ( +
+
+
+
+ + + + +
{lastConnectionTime && lastConnectionTime.event === CONNECTED && ( + + Last connected: {new Date(lastConnectionTime.timestamp).toLocaleString()} + + )}
+ + {/* action buttons */} + +
+
+ ) + } + + // for standalone instances + return ( +
+
+
+
+ +
+ +
+ + + + {connection.connectionDetails.alias && ( + + ({label}) + )} +
+ + {lastConnectionTime && lastConnectionTime.event === CONNECTED && ( + + Last connected: {new Date(lastConnectionTime.timestamp).toLocaleString()} + + )} +
+
+
+ + {/* action buttons */} +
+ {isConnected && !hideOpenButton && ( + + )} + +
+
+
+ ) +} diff --git a/apps/frontend/src/components/connection/__tests__/text-truncation.test.tsx b/apps/frontend/src/components/connection/__tests__/text-truncation.test.tsx index c4c0562a..eccea47b 100644 --- a/apps/frontend/src/components/connection/__tests__/text-truncation.test.tsx +++ b/apps/frontend/src/components/connection/__tests__/text-truncation.test.tsx @@ -1,201 +1,201 @@ -import { render } from "@testing-library/react" -import { Provider } from "react-redux" -import { BrowserRouter } from "react-router" -import { configureStore } from "@reduxjs/toolkit" -import { CONNECTED, DISCONNECTED } from "@common/src/constants" -import { ConnectionEntry } from "../ConnectionEntry" -import { ClusterConnectionGroup } from "../ClusterConnectionGroup" -import connectionReducer from "@/state/valkey-features/connection/connectionSlice" - -// Mock store setup -const createMockStore = (connections: Record = {}) => { - return configureStore({ - reducer: { - valkeyConnection: connectionReducer, - }, - preloadedState: { - valkeyConnection: { - connections, - }, - }, - }) -} - -const mockConnection = { - status: CONNECTED, - connectionDetails: { - host: "very-long-hostname-that-should-be-truncated.example.com", - port: 6379, - alias: "Very Long Alias Name That Should Be Truncated When Displayed", - }, - connectionHistory: [ - { - event: CONNECTED, - timestamp: Date.now(), - }, - ], -} - -const TestWrapper = ({ children, store }: { children: React.ReactNode; store: Record }) => ( - - - {children} - - -) - -describe("Text Truncation Improvements", () => { - describe("ConnectionEntry", () => { - it("should apply truncate class and title attribute to connection links", () => { - const store = createMockStore() - const { container } = render( - - - , - ) - - // Check that truncate class is applied to connection links - const linkElements = container.querySelectorAll("a[title]") - expect(linkElements.length).toBeGreaterThan(0) - - // Verify the link has both truncate class and title attribute - const connectionLink = linkElements[0] - expect(connectionLink).toHaveAttribute("title") - - // Check that the parent button has truncate class - const buttonWithTruncate = container.querySelector(".truncate") - expect(buttonWithTruncate).toBeInTheDocument() - }) - - it("should apply truncate class and title to last connection time", () => { - const store = createMockStore() - const { container } = render( - - - , - ) - - // Check for last connection time in Typography component (renders as p by default) - const lastConnectionElements = container.querySelectorAll("p") - const hasLastConnectionElement = Array.from(lastConnectionElements).some((p) => - p.textContent?.includes("Last connected:"), - ) - expect(hasLastConnectionElement).toBe(true) - }) - - it("should apply truncate class and title to alias display", () => { - const store = createMockStore() - const { container } = render( - - - , - ) - - // Check for alias in Typography component with code variant (renders as element) with title - const aliasElements = container.querySelectorAll("code[title]") - const hasAliasElement = Array.from(aliasElements).some((code) => - code.textContent?.includes("(") && code.textContent?.includes(")"), - ) - expect(hasAliasElement).toBe(true) - }) - }) - - describe("ClusterConnectionGroup", () => { - it("should apply truncate class and title attribute to cluster name", () => { - const store = createMockStore({ - "conn1": mockConnection, - "conn2": { ...mockConnection, status: DISCONNECTED }, - }) - - const connections = [ - { connectionId: "conn1", connection: mockConnection }, - { connectionId: "conn2", connection: { ...mockConnection, status: DISCONNECTED } }, - ] - - const { container } = render( - - - , - ) - - // Since there's a connected instance, cluster name should be a clickable link - const clusterNameLink = container.querySelector("a[title]") - expect(clusterNameLink).toBeInTheDocument() - expect(clusterNameLink).toHaveAttribute("title") - - // The link should have truncate class through its parent button - const buttonWithTruncate = container.querySelector("button .truncate, .truncate") - expect(buttonWithTruncate).toBeInTheDocument() - }) - - it("should apply truncate class and title attribute to cluster name when no connections", () => { - const disconnectedConnection = { ...mockConnection, status: DISCONNECTED } - const store = createMockStore({ - "conn1": disconnectedConnection, - "conn2": disconnectedConnection, - }) - - const connections = [ - { connectionId: "conn1", connection: disconnectedConnection }, - { connectionId: "conn2", connection: disconnectedConnection }, - ] - - const { container } = render( - - - , - ) - - // Since there are no connected instances, cluster name should be a code element with Typography - const clusterNameElement = container.querySelector("code[title]") - expect(clusterNameElement).toBeInTheDocument() - expect(clusterNameElement).toHaveAttribute("title") - - // Check that it has truncate class - expect(clusterNameElement).toHaveClass("truncate") - }) - - it("should apply truncate class and title to instance count text", () => { - const store = createMockStore({ - "conn1": mockConnection, - "conn2": { ...mockConnection, status: DISCONNECTED }, - }) - - const connections = [ - { connectionId: "conn1", connection: mockConnection }, - { connectionId: "conn2", connection: { ...mockConnection, status: DISCONNECTED } }, - ] - - const { container } = render( - - - , - ) - - // Check for instance count in Typography component with code variant (renders as element) with truncate and title - const instanceCountElements = container.querySelectorAll("code.truncate[title]") - const hasInstanceCountElement = Array.from(instanceCountElements).some((code) => - code.textContent?.includes("instance"), - ) - expect(hasInstanceCountElement).toBe(true) - }) - }) -}) +import { render } from "@testing-library/react" +import { Provider } from "react-redux" +import { BrowserRouter } from "react-router" +import { configureStore } from "@reduxjs/toolkit" +import { CONNECTED, DISCONNECTED } from "@common/src/constants" +import { ConnectionEntry } from "../ConnectionEntry" +import { ClusterConnectionGroup } from "../ClusterConnectionGroup" +import connectionReducer from "@/state/valkey-features/connection/connectionSlice" + +// Mock store setup +const createMockStore = (connections: Record = {}) => { + return configureStore({ + reducer: { + valkeyConnection: connectionReducer, + }, + preloadedState: { + valkeyConnection: { + connections, + }, + }, + }) +} + +const mockConnection = { + status: CONNECTED, + connectionDetails: { + host: "very-long-hostname-that-should-be-truncated.example.com", + port: 6379, + alias: "Very Long Alias Name That Should Be Truncated When Displayed", + }, + connectionHistory: [ + { + event: CONNECTED, + timestamp: Date.now(), + }, + ], +} + +const TestWrapper = ({ children, store }: { children: React.ReactNode; store: Record }) => ( + + + {children} + + +) + +describe("Text Truncation Improvements", () => { + describe("ConnectionEntry", () => { + it("should apply truncate class and title attribute to connection links", () => { + const store = createMockStore() + const { container } = render( + + + , + ) + + // Check that truncate class is applied to connection links + const linkElements = container.querySelectorAll("a[title]") + expect(linkElements.length).toBeGreaterThan(0) + + // Verify the link has both truncate class and title attribute + const connectionLink = linkElements[0] + expect(connectionLink).toHaveAttribute("title") + + // Check that the parent button has truncate class + const buttonWithTruncate = container.querySelector(".truncate") + expect(buttonWithTruncate).toBeInTheDocument() + }) + + it("should apply truncate class and title to last connection time", () => { + const store = createMockStore() + const { container } = render( + + + , + ) + + // Check for last connection time in Typography component (renders as p by default) + const lastConnectionElements = container.querySelectorAll("p") + const hasLastConnectionElement = Array.from(lastConnectionElements).some((p) => + p.textContent?.includes("Last connected:"), + ) + expect(hasLastConnectionElement).toBe(true) + }) + + it("should apply truncate class and title to alias display", () => { + const store = createMockStore() + const { container } = render( + + + , + ) + + // Check for alias in Typography component with code variant (renders as element) with title + const aliasElements = container.querySelectorAll("code[title]") + const hasAliasElement = Array.from(aliasElements).some((code) => + code.textContent?.includes("(") && code.textContent?.includes(")"), + ) + expect(hasAliasElement).toBe(true) + }) + }) + + describe("ClusterConnectionGroup", () => { + it("should apply truncate class and title attribute to cluster name", () => { + const store = createMockStore({ + "conn1": mockConnection, + "conn2": { ...mockConnection, status: DISCONNECTED }, + }) + + const connections = [ + { connectionId: "conn1", connection: mockConnection }, + { connectionId: "conn2", connection: { ...mockConnection, status: DISCONNECTED } }, + ] + + const { container } = render( + + + , + ) + + // Since there's a connected instance, cluster name should be a clickable link + const clusterNameLink = container.querySelector("a[title]") + expect(clusterNameLink).toBeInTheDocument() + expect(clusterNameLink).toHaveAttribute("title") + + // The link should have truncate class through its parent button + const buttonWithTruncate = container.querySelector("button .truncate, .truncate") + expect(buttonWithTruncate).toBeInTheDocument() + }) + + it("should apply truncate class and title attribute to cluster name when no connections", () => { + const disconnectedConnection = { ...mockConnection, status: DISCONNECTED } + const store = createMockStore({ + "conn1": disconnectedConnection, + "conn2": disconnectedConnection, + }) + + const connections = [ + { connectionId: "conn1", connection: disconnectedConnection }, + { connectionId: "conn2", connection: disconnectedConnection }, + ] + + const { container } = render( + + + , + ) + + // Since there are no connected instances, cluster name should be a code element with Typography + const clusterNameElement = container.querySelector("code[title]") + expect(clusterNameElement).toBeInTheDocument() + expect(clusterNameElement).toHaveAttribute("title") + + // Check that it has truncate class + expect(clusterNameElement).toHaveClass("truncate") + }) + + it("should apply truncate class and title to instance count text", () => { + const store = createMockStore({ + "conn1": mockConnection, + "conn2": { ...mockConnection, status: DISCONNECTED }, + }) + + const connections = [ + { connectionId: "conn1", connection: mockConnection }, + { connectionId: "conn2", connection: { ...mockConnection, status: DISCONNECTED } }, + ] + + const { container } = render( + + + , + ) + + // Check for instance count in Typography component with code variant (renders as element) with truncate and title + const instanceCountElements = container.querySelectorAll("code.truncate[title]") + const hasInstanceCountElement = Array.from(instanceCountElements).some((code) => + code.textContent?.includes("instance"), + ) + expect(hasInstanceCountElement).toBe(true) + }) + }) +}) diff --git a/apps/frontend/src/components/dashboard/Dashboard.tsx b/apps/frontend/src/components/dashboard/Dashboard.tsx index f2e1170f..b5ef807e 100644 --- a/apps/frontend/src/components/dashboard/Dashboard.tsx +++ b/apps/frontend/src/components/dashboard/Dashboard.tsx @@ -1,208 +1,208 @@ -import { useState } from "react" -import { useSelector } from "react-redux" -import { LayoutDashboard, Search } from "lucide-react" -import { useParams } from "react-router" -import { formatBytes } from "@common/src/bytes-conversion" -import { Database } from "lucide-react" -import { accordionDescriptions } from "@common/src/dashboard-metrics" -import { singleMetricDescriptions } from "@common/src/dashboard-metrics" -import { AppHeader } from "../ui/app-header" -import Accordion from "../ui/accordion" -import DonutChart from "../ui/donut-chart" -import CpuMemoryUsage from "./cpu-memory-usage" -import { SplitPanel } from "../ui/split-panel" -import { Panel } from "../ui/panel" -import { Input } from "../ui/input" -import { StatCard } from "../ui/stat-card" -import RouteContainer from "../ui/route-container" -import { Typography } from "../ui/typography" -import { selectData } from "@/state/valkey-features/info/infoSelectors.ts" - -export function Dashboard() { - const { id } = useParams() - const infoData = useSelector(selectData(id!)) || {} - const [searchQuery, setSearchQuery] = useState("") - - if (!infoData) { - return ( -
- } - title="Dashboard" - /> -
- - Loading metrics… - -
-
- ) - } - - const memoryUsageMetrics = { - used_memory: infoData.used_memory, - used_memory_dataset: infoData.used_memory_dataset, - used_memory_functions: infoData.used_memory_functions, - used_memory_vm_eval: infoData.used_memory_vm_eval, - used_memory_peak: infoData.used_memory_peak, - used_memory_scripts: infoData.used_memory_scripts, - total_system_memory: infoData.total_system_memory, - } - - const upTimeMetrics = { - evicted_scripts: infoData.evicted_scripts, - uptime_in_seconds: infoData.uptime_in_seconds, - total_net_input_bytes: infoData.total_net_input_bytes, - total_net_output_bytes: infoData.total_net_output_bytes, - } - - const replicationPersistenceMetrics = { - rdb_bgsave_in_progress: infoData.rdb_bgsave_in_progress, - rdb_changes_since_last_save: infoData.rdb_changes_since_last_save, - rdb_saves: infoData.rdb_saves, - mem_replication_backlog: infoData.mem_replication_backlog, - sync_full: infoData.sync_full, - repl_backlog_active: infoData.repl_backlog_active, - } - - const clientConnectivityMetrics = { - blocked_clients: infoData.blocked_clients, - clients_in_timeout_table: infoData.clients_in_timeout_table, - connected_clients: infoData.connected_clients, - connected_slaves: infoData.connected_slaves, - total_connections_received: infoData.total_connections_received, - evicted_clients: infoData.evicted_clients, - rejected_connections: infoData.rejected_connections, - total_reads_processed: infoData.total_reads_processed, - total_writes_processed: infoData.total_writes_processed, - tracking_clients: infoData.tracking_clients, - watching_clients: infoData.watching_clients, - } - - const commandExecutionMetrics = { - total_commands_processed: infoData.total_commands_processed, - total_blocking_keys: infoData.total_blocking_keys, - total_error_replies: infoData.total_error_replies, - total_watched_keys: infoData.total_watched_keys, - unexpected_error_replies: infoData.unexpected_error_replies, - } - - const dataEffectivenessAndEvictionMetrics = { - evicted_keys: infoData.evicted_keys, - expired_keys: infoData.expired_keys, - expired_stale_perc: infoData.expired_stale_perc, - keyspace_hits: infoData.keyspace_hits, - keyspace_misses: infoData.keyspace_misses, - number_of_cached_scripts: infoData.number_of_cached_scripts, - number_of_functions: infoData.number_of_functions, - } - - const messagingMetrics = { - pubsubshard_channels: infoData.pubsubshard_channels, - pubsub_channels: infoData.pubsub_channels, - pubsub_clients: infoData.pubsub_clients, - pubsub_patterns: infoData.pubsub_patterns, - } - - return ( - - } - title="Dashboard" - /> -
- {/* Memory Area */} -
-
- } - label="Total Memory" - value={formatBytes(memoryUsageMetrics.total_system_memory || 0)} - /> -
-
- } - label="Used Memory" - value={formatBytes(memoryUsageMetrics.used_memory || 0)} - /> -
-
- - {/* Search or Filtering Input */} -
- - setSearchQuery(e.target.value)} - placeholder="Search metrics..." - type="text" - value={searchQuery} - /> -
- - - - - - - - - } - right={ - - - - } - rightClassName="pl-2" - /> - {/* cpu and memory usage charts */} - -
-
- ) -} +import { useState } from "react" +import { useSelector } from "react-redux" +import { LayoutDashboard, Search } from "lucide-react" +import { useParams } from "react-router" +import { formatBytes } from "@common/src/bytes-conversion" +import { Database } from "lucide-react" +import { accordionDescriptions } from "@common/src/dashboard-metrics" +import { singleMetricDescriptions } from "@common/src/dashboard-metrics" +import { AppHeader } from "../ui/app-header" +import Accordion from "../ui/accordion" +import DonutChart from "../ui/donut-chart" +import CpuMemoryUsage from "./cpu-memory-usage" +import { SplitPanel } from "../ui/split-panel" +import { Panel } from "../ui/panel" +import { Input } from "../ui/input" +import { StatCard } from "../ui/stat-card" +import RouteContainer from "../ui/route-container" +import { Typography } from "../ui/typography" +import { selectData } from "@/state/valkey-features/info/infoSelectors.ts" + +export function Dashboard() { + const { id } = useParams() + const infoData = useSelector(selectData(id!)) || {} + const [searchQuery, setSearchQuery] = useState("") + + if (!infoData) { + return ( +
+ } + title="Dashboard" + /> +
+ + Loading metrics… + +
+
+ ) + } + + const memoryUsageMetrics = { + used_memory: infoData.used_memory, + used_memory_dataset: infoData.used_memory_dataset, + used_memory_functions: infoData.used_memory_functions, + used_memory_vm_eval: infoData.used_memory_vm_eval, + used_memory_peak: infoData.used_memory_peak, + used_memory_scripts: infoData.used_memory_scripts, + total_system_memory: infoData.total_system_memory, + } + + const upTimeMetrics = { + evicted_scripts: infoData.evicted_scripts, + uptime_in_seconds: infoData.uptime_in_seconds, + total_net_input_bytes: infoData.total_net_input_bytes, + total_net_output_bytes: infoData.total_net_output_bytes, + } + + const replicationPersistenceMetrics = { + rdb_bgsave_in_progress: infoData.rdb_bgsave_in_progress, + rdb_changes_since_last_save: infoData.rdb_changes_since_last_save, + rdb_saves: infoData.rdb_saves, + mem_replication_backlog: infoData.mem_replication_backlog, + sync_full: infoData.sync_full, + repl_backlog_active: infoData.repl_backlog_active, + } + + const clientConnectivityMetrics = { + blocked_clients: infoData.blocked_clients, + clients_in_timeout_table: infoData.clients_in_timeout_table, + connected_clients: infoData.connected_clients, + connected_slaves: infoData.connected_slaves, + total_connections_received: infoData.total_connections_received, + evicted_clients: infoData.evicted_clients, + rejected_connections: infoData.rejected_connections, + total_reads_processed: infoData.total_reads_processed, + total_writes_processed: infoData.total_writes_processed, + tracking_clients: infoData.tracking_clients, + watching_clients: infoData.watching_clients, + } + + const commandExecutionMetrics = { + total_commands_processed: infoData.total_commands_processed, + total_blocking_keys: infoData.total_blocking_keys, + total_error_replies: infoData.total_error_replies, + total_watched_keys: infoData.total_watched_keys, + unexpected_error_replies: infoData.unexpected_error_replies, + } + + const dataEffectivenessAndEvictionMetrics = { + evicted_keys: infoData.evicted_keys, + expired_keys: infoData.expired_keys, + expired_stale_perc: infoData.expired_stale_perc, + keyspace_hits: infoData.keyspace_hits, + keyspace_misses: infoData.keyspace_misses, + number_of_cached_scripts: infoData.number_of_cached_scripts, + number_of_functions: infoData.number_of_functions, + } + + const messagingMetrics = { + pubsubshard_channels: infoData.pubsubshard_channels, + pubsub_channels: infoData.pubsub_channels, + pubsub_clients: infoData.pubsub_clients, + pubsub_patterns: infoData.pubsub_patterns, + } + + return ( + + } + title="Dashboard" + /> +
+ {/* Memory Area */} +
+
+ } + label="Total Memory" + value={formatBytes(memoryUsageMetrics.total_system_memory || 0)} + /> +
+
+ } + label="Used Memory" + value={formatBytes(memoryUsageMetrics.used_memory || 0)} + /> +
+
+ + {/* Search or Filtering Input */} +
+ + setSearchQuery(e.target.value)} + placeholder="Search metrics..." + type="text" + value={searchQuery} + /> +
+ + + + + + + + + } + right={ + + + + } + rightClassName="pl-2" + /> + {/* cpu and memory usage charts */} + +
+
+ ) +} diff --git a/apps/frontend/src/components/dashboard/cpu-memory-usage.tsx b/apps/frontend/src/components/dashboard/cpu-memory-usage.tsx index 1da6a5f0..2f5a0758 100644 --- a/apps/frontend/src/components/dashboard/cpu-memory-usage.tsx +++ b/apps/frontend/src/components/dashboard/cpu-memory-usage.tsx @@ -1,140 +1,140 @@ -import { useEffect, useState } from "react" -import { useSelector } from "react-redux" -import { useParams } from "react-router" -import { formatBytes } from "@common/src/bytes-conversion" -import LineChartComponent from "../ui/line-chart" -import { ButtonGroup } from "../ui/button-group" -import { ChartSection } from "../ui/chart-section" -import { cpuUsageRequested, selectCpuUsage } from "@/state/valkey-features/cpu/cpuSlice.ts" -import { useAppDispatch } from "@/hooks/hooks" -import { memoryUsageRequested, selectMemoryUsage } from "@/state/valkey-features/memory/memorySlice" - -const colors = [ - "var(--chart-1)", - "var(--chart-2)", - "var(--chart-3)", - "var(--chart-4)", - "var(--chart-5)", -] - -export default function CpuMemoryUsage() { - const { id, clusterId } = useParams() - const dispatch = useAppDispatch() - const cpuUsageData = useSelector(selectCpuUsage(id ?? "")) - const memoryUsageData = useSelector(selectMemoryUsage(id ?? "")) - const [cpuTimeRange, setCpuTimeRange] = useState("1h") - const [memoryTimeRange, setMemoryTimeRange] = useState("1h") - - // for cpu - useEffect(() => { - if (id) { - dispatch(cpuUsageRequested({ connectionId: id, clusterId, timeRange: cpuTimeRange })) - } - }, [id, clusterId, dispatch, cpuTimeRange]) - - // for memory - useEffect(() => { - if (id) { - dispatch(memoryUsageRequested({ connectionId: id, clusterId, timeRange: memoryTimeRange })) - } - }, [id, clusterId, dispatch, memoryTimeRange]) - - const memoryMetrics = memoryUsageData ? Object.entries(memoryUsageData) : [] - - // format metric name - const formatMetricName = (key: string) => { - const formatted = key - .split("_") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" ") - - // Replace outdated terminology - return formatted.replace(/\bSlave(s)?\b/g, "Replica$1") - } - - // format metric unit - const formatMetricUnit = (key: string) => { - const k = key.toLowerCase() - return k.includes("bytes") ? "(bytes)" - : k.includes("percentage") ? "(%)" - : k.includes("ratio") ? "(ratio)" - : "count" - } - - // format y-axis value - const getValueFormatter = (key: string) => { - if (key.toLowerCase().includes("bytes")) { - return (value: number) => formatBytes(value) - } - return undefined - } - - return ( - <> - {/* CPU Usage Section */} - - } - className="mt-4" - emptyMessage="CPU usage data will appear here" - isEmpty={!cpuUsageData || cpuUsageData.length === 0} - subtitle="Real-time CPU utilization monitoring" - title="CPU Usage Over Time" - > - - - - {/* Memory Usage Section */} - - } - className="mt-4" - emptyMessage="Memory usage data will appear here" - isEmpty={memoryMetrics.length === 0} - subtitle="Real-time Memory utilization monitoring" - title="Memory Usage Over Time" - > -
- {memoryMetrics?.map(([key, metric], index) => ( - - - - ))} -
-
- - ) -} +import { useEffect, useState } from "react" +import { useSelector } from "react-redux" +import { useParams } from "react-router" +import { formatBytes } from "@common/src/bytes-conversion" +import LineChartComponent from "../ui/line-chart" +import { ButtonGroup } from "../ui/button-group" +import { ChartSection } from "../ui/chart-section" +import { cpuUsageRequested, selectCpuUsage } from "@/state/valkey-features/cpu/cpuSlice.ts" +import { useAppDispatch } from "@/hooks/hooks" +import { memoryUsageRequested, selectMemoryUsage } from "@/state/valkey-features/memory/memorySlice" + +const colors = [ + "var(--chart-1)", + "var(--chart-2)", + "var(--chart-3)", + "var(--chart-4)", + "var(--chart-5)", +] + +export default function CpuMemoryUsage() { + const { id, clusterId } = useParams() + const dispatch = useAppDispatch() + const cpuUsageData = useSelector(selectCpuUsage(id ?? "")) + const memoryUsageData = useSelector(selectMemoryUsage(id ?? "")) + const [cpuTimeRange, setCpuTimeRange] = useState("1h") + const [memoryTimeRange, setMemoryTimeRange] = useState("1h") + + // for cpu + useEffect(() => { + if (id) { + dispatch(cpuUsageRequested({ connectionId: id, clusterId, timeRange: cpuTimeRange })) + } + }, [id, clusterId, dispatch, cpuTimeRange]) + + // for memory + useEffect(() => { + if (id) { + dispatch(memoryUsageRequested({ connectionId: id, clusterId, timeRange: memoryTimeRange })) + } + }, [id, clusterId, dispatch, memoryTimeRange]) + + const memoryMetrics = memoryUsageData ? Object.entries(memoryUsageData) : [] + + // format metric name + const formatMetricName = (key: string) => { + const formatted = key + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" ") + + // Replace outdated terminology + return formatted.replace(/\bSlave(s)?\b/g, "Replica$1") + } + + // format metric unit + const formatMetricUnit = (key: string) => { + const k = key.toLowerCase() + return k.includes("bytes") ? "(bytes)" + : k.includes("percentage") ? "(%)" + : k.includes("ratio") ? "(ratio)" + : "count" + } + + // format y-axis value + const getValueFormatter = (key: string) => { + if (key.toLowerCase().includes("bytes")) { + return (value: number) => formatBytes(value) + } + return undefined + } + + return ( + <> + {/* CPU Usage Section */} + + } + className="mt-4" + emptyMessage="CPU usage data will appear here" + isEmpty={!cpuUsageData || cpuUsageData.length === 0} + subtitle="Real-time CPU utilization monitoring" + title="CPU Usage Over Time" + > + + + + {/* Memory Usage Section */} + + } + className="mt-4" + emptyMessage="Memory usage data will appear here" + isEmpty={memoryMetrics.length === 0} + subtitle="Real-time Memory utilization monitoring" + title="Memory Usage Over Time" + > +
+ {memoryMetrics?.map(([key, metric], index) => ( + + + + ))} +
+
+ + ) +} diff --git a/apps/frontend/src/components/key-browser/add-key.tsx b/apps/frontend/src/components/key-browser/add-key.tsx index 99734359..8150093f 100644 --- a/apps/frontend/src/components/key-browser/add-key.tsx +++ b/apps/frontend/src/components/key-browser/add-key.tsx @@ -1,394 +1,394 @@ -import React, { useState } from "react" -import { X } from "lucide-react" -import { useParams } from "react-router" -import { useSelector } from "react-redux" -import { validators } from "@common/src/key-validators" -import * as R from "ramda" -import * as Dialog from "@radix-ui/react-dialog" -import { KEY_TYPES } from "@common/src/constants" -import { HashFields, ListFields, StringFields, SetFields, ZSetFields, StreamFields, JsonFields } from "./key-types" -import { useAppDispatch } from "@/hooks/hooks" -import { addKeyRequested } from "@/state/valkey-features/keys/keyBrowserSlice" -import { selectJsonModuleAvailable } from "@/state/valkey-features/connection/connectionSelectors" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { Select } from "@/components/ui/select" -import { Typography } from "@/components/ui/typography" - -interface AddNewKeyProps { - onClose: () => void; -} - -export default function AddNewKey({ onClose }: AddNewKeyProps) { - const { id } = useParams() - const dispatch = useAppDispatch() - const jsonModuleAvailable = useSelector(selectJsonModuleAvailable(id!)) - - const [keyType, setKeyType] = useState(KEY_TYPES.STRING) - const [keyName, setKeyName] = useState("") - const [ttl, setTtl] = useState("") - const [value, setValue] = useState("") - const [error, setError] = useState("") - const [hashFields, setHashFields] = useState([{ field: "", value: "" }]) - const [listFields, setListFields] = useState([""]) - const [setFields, setSetFields] = useState([""]) - const [zsetFields, setZsetFields] = useState([{ key: "", value: "" }]) - const [streamFields, setStreamFields] = useState([{ field: "", value: "" }]) - const [streamEntryId, setStreamEntryId] = useState("") - - const addHashField = () => { - setHashFields([...hashFields, { field: "", value: "" }]) - } - - const removeHashField = (index: number) => { - setHashFields(hashFields.filter((_, i) => i !== index)) - } - - const updateHashField = ( - index: number, - key: "field" | "value", - val: string, - ) => { - const updated = [...hashFields] - updated[index][key] = val - setHashFields(updated) - } - - const addListField = () => { - setListFields([...listFields, ""]) - } - - const removeListField = (index: number) => { - setListFields(listFields.filter((_, i) => i !== index)) - } - - const addSetField = () => { - setSetFields([...setFields, ""]) - } - const removeSetField = (index: number) => { - setSetFields(setFields.filter((_, i) => i !== index)) - } - - const addZsetField = () => { - setZsetFields([...zsetFields, { key: "", value: "" }]) - } - - const removeZsetField = (index: number) => { - setZsetFields(zsetFields.filter((_, i) => i !== index)) - } - - const updateZsetField = ( - index: number, - field: "key" | "value", - val: string, - ) => { - const updated = [...zsetFields] - updated[index][field] = val - setZsetFields(updated) - } - - const addStreamField = () => { - setStreamFields([...streamFields, { field: "", value: "" }]) - } - - const removeStreamField = (index: number) => { - setStreamFields(streamFields.filter((_, i) => i !== index)) - } - - const updateStreamField = ( - index: number, - field: "field" | "value", - val: string, - ) => { - const updated = [...streamFields] - updated[index][field] = val - setStreamFields(updated) - } - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault() - setError("") - - const parsedTtl = ttl ? parseInt(ttl, 10) : undefined - - const validationData = { - keyName, - keyType, - value, - ttl: parsedTtl, - hashFields: keyType === KEY_TYPES.HASH ? hashFields : undefined, - listFields: keyType === KEY_TYPES.LIST ? listFields : undefined, - setFields: keyType === KEY_TYPES.SET ? setFields : undefined, - zsetFields: keyType === KEY_TYPES.ZSET ? zsetFields : undefined, - streamFields: keyType === KEY_TYPES.STREAM ? streamFields : undefined, - } - - const validator = validators[keyType as keyof typeof validators] || validators["undefined"] - - // Validate - const errors = validator(validationData) - if (R.isNotEmpty(errors)) { - - return setError(errors) - } - - // dispatching - if (id) { - const basePayload = { - connectionId: id, - key: keyName.trim(), - keyType, - ttl: parsedTtl && parsedTtl > 0 ? parsedTtl : undefined, - } - - switch (keyType) { - case KEY_TYPES.STRING: - dispatch( - addKeyRequested({ - ...basePayload, - value: value.trim(), - }), - ) - break - case KEY_TYPES.HASH: { - // before dispatching, filtering out the empty fields - const validFields = hashFields - .filter((field) => field.field.trim() && field.value.trim()) - .map((field) => ({ - field: field.field.trim(), - value: field.value.trim(), - })) - - dispatch( - addKeyRequested({ - ...basePayload, - fields: validFields, - }), - ) - break - } - case KEY_TYPES.LIST: { - // before dispatching, filtering out the empty fields - const validFields = listFields - .filter((field) => field.trim()) - .map((field) => field.trim()) - - dispatch( - addKeyRequested({ - ...basePayload, - values: validFields, - }), - ) - break - } - case KEY_TYPES.SET: { - // before dispatching, filtering out the empty fields - const validFields = setFields - .filter((field) => field.trim()) - .map((field) => field.trim()) - - dispatch( - addKeyRequested({ - ...basePayload, - values: validFields, - }), - ) - break - } - case KEY_TYPES.ZSET: { - // before dispatching, filtering out the empty fields and converting scores to numbers - const validMembers = zsetFields - .filter((field) => field.key.trim() && field.value.trim()) - .map((field) => ({ - key: field.key.trim(), - value: parseFloat(field.value), - })) - - dispatch( - addKeyRequested({ - ...basePayload, - zsetMembers: validMembers, - }), - ) - break - } - case KEY_TYPES.STREAM: { - // before dispatching, filtering out the empty fields - const validFields = streamFields - .filter((field) => field.field.trim() && field.value.trim()) - .map((field) => ({ - field: field.field.trim(), - value: field.value.trim(), - })) - - dispatch( - addKeyRequested({ - ...basePayload, - fields: validFields, - streamEntryId: streamEntryId.trim() || undefined, - }), - ) - break - } - case KEY_TYPES.JSON: - dispatch( - addKeyRequested({ - ...basePayload, - value: value.trim(), - }), - ) - break - } - - onClose() - } - } - - return ( - - - - -
-
-
- - Add Key - - - - -
-
-
-
-
-
- - -
-
-
-
- - setTtl(e.target.value)} - placeholder="Enter TTL, Default: -1 (no expiration)" - type="number" - value={ttl} - /> -
-
-
-
-
- - setKeyName(e.target.value)} - placeholder="Enter key name" - type="text" - value={keyName} - /> -
-
- - Key Elements - -
-
- {keyType === KEY_TYPES.STRING ? ( - - ) : keyType === KEY_TYPES.LIST ? ( - - ) : keyType === KEY_TYPES.HASH ? ( - - ) : keyType === KEY_TYPES.SET ? ( - - ) : keyType === KEY_TYPES.ZSET ? ( - - ) : keyType === KEY_TYPES.STREAM ? ( - - ) : keyType === KEY_TYPES.JSON ? ( - - ) : ( - - Select a key type - - )} -
-
- {error && ( - - {error} - - )} - -
- - -
-
-
-
-
- ) -} +import React, { useState } from "react" +import { X } from "lucide-react" +import { useParams } from "react-router" +import { useSelector } from "react-redux" +import { validators } from "@common/src/key-validators" +import * as R from "ramda" +import * as Dialog from "@radix-ui/react-dialog" +import { KEY_TYPES } from "@common/src/constants" +import { HashFields, ListFields, StringFields, SetFields, ZSetFields, StreamFields, JsonFields } from "./key-types" +import { useAppDispatch } from "@/hooks/hooks" +import { addKeyRequested } from "@/state/valkey-features/keys/keyBrowserSlice" +import { selectJsonModuleAvailable } from "@/state/valkey-features/connection/connectionSelectors" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Select } from "@/components/ui/select" +import { Typography } from "@/components/ui/typography" + +interface AddNewKeyProps { + onClose: () => void; +} + +export default function AddNewKey({ onClose }: AddNewKeyProps) { + const { id } = useParams() + const dispatch = useAppDispatch() + const jsonModuleAvailable = useSelector(selectJsonModuleAvailable(id!)) + + const [keyType, setKeyType] = useState(KEY_TYPES.STRING) + const [keyName, setKeyName] = useState("") + const [ttl, setTtl] = useState("") + const [value, setValue] = useState("") + const [error, setError] = useState("") + const [hashFields, setHashFields] = useState([{ field: "", value: "" }]) + const [listFields, setListFields] = useState([""]) + const [setFields, setSetFields] = useState([""]) + const [zsetFields, setZsetFields] = useState([{ key: "", value: "" }]) + const [streamFields, setStreamFields] = useState([{ field: "", value: "" }]) + const [streamEntryId, setStreamEntryId] = useState("") + + const addHashField = () => { + setHashFields([...hashFields, { field: "", value: "" }]) + } + + const removeHashField = (index: number) => { + setHashFields(hashFields.filter((_, i) => i !== index)) + } + + const updateHashField = ( + index: number, + key: "field" | "value", + val: string, + ) => { + const updated = [...hashFields] + updated[index][key] = val + setHashFields(updated) + } + + const addListField = () => { + setListFields([...listFields, ""]) + } + + const removeListField = (index: number) => { + setListFields(listFields.filter((_, i) => i !== index)) + } + + const addSetField = () => { + setSetFields([...setFields, ""]) + } + const removeSetField = (index: number) => { + setSetFields(setFields.filter((_, i) => i !== index)) + } + + const addZsetField = () => { + setZsetFields([...zsetFields, { key: "", value: "" }]) + } + + const removeZsetField = (index: number) => { + setZsetFields(zsetFields.filter((_, i) => i !== index)) + } + + const updateZsetField = ( + index: number, + field: "key" | "value", + val: string, + ) => { + const updated = [...zsetFields] + updated[index][field] = val + setZsetFields(updated) + } + + const addStreamField = () => { + setStreamFields([...streamFields, { field: "", value: "" }]) + } + + const removeStreamField = (index: number) => { + setStreamFields(streamFields.filter((_, i) => i !== index)) + } + + const updateStreamField = ( + index: number, + field: "field" | "value", + val: string, + ) => { + const updated = [...streamFields] + updated[index][field] = val + setStreamFields(updated) + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + setError("") + + const parsedTtl = ttl ? parseInt(ttl, 10) : undefined + + const validationData = { + keyName, + keyType, + value, + ttl: parsedTtl, + hashFields: keyType === KEY_TYPES.HASH ? hashFields : undefined, + listFields: keyType === KEY_TYPES.LIST ? listFields : undefined, + setFields: keyType === KEY_TYPES.SET ? setFields : undefined, + zsetFields: keyType === KEY_TYPES.ZSET ? zsetFields : undefined, + streamFields: keyType === KEY_TYPES.STREAM ? streamFields : undefined, + } + + const validator = validators[keyType as keyof typeof validators] || validators["undefined"] + + // Validate + const errors = validator(validationData) + if (R.isNotEmpty(errors)) { + + return setError(errors) + } + + // dispatching + if (id) { + const basePayload = { + connectionId: id, + key: keyName.trim(), + keyType, + ttl: parsedTtl && parsedTtl > 0 ? parsedTtl : undefined, + } + + switch (keyType) { + case KEY_TYPES.STRING: + dispatch( + addKeyRequested({ + ...basePayload, + value: value.trim(), + }), + ) + break + case KEY_TYPES.HASH: { + // before dispatching, filtering out the empty fields + const validFields = hashFields + .filter((field) => field.field.trim() && field.value.trim()) + .map((field) => ({ + field: field.field.trim(), + value: field.value.trim(), + })) + + dispatch( + addKeyRequested({ + ...basePayload, + fields: validFields, + }), + ) + break + } + case KEY_TYPES.LIST: { + // before dispatching, filtering out the empty fields + const validFields = listFields + .filter((field) => field.trim()) + .map((field) => field.trim()) + + dispatch( + addKeyRequested({ + ...basePayload, + values: validFields, + }), + ) + break + } + case KEY_TYPES.SET: { + // before dispatching, filtering out the empty fields + const validFields = setFields + .filter((field) => field.trim()) + .map((field) => field.trim()) + + dispatch( + addKeyRequested({ + ...basePayload, + values: validFields, + }), + ) + break + } + case KEY_TYPES.ZSET: { + // before dispatching, filtering out the empty fields and converting scores to numbers + const validMembers = zsetFields + .filter((field) => field.key.trim() && field.value.trim()) + .map((field) => ({ + key: field.key.trim(), + value: parseFloat(field.value), + })) + + dispatch( + addKeyRequested({ + ...basePayload, + zsetMembers: validMembers, + }), + ) + break + } + case KEY_TYPES.STREAM: { + // before dispatching, filtering out the empty fields + const validFields = streamFields + .filter((field) => field.field.trim() && field.value.trim()) + .map((field) => ({ + field: field.field.trim(), + value: field.value.trim(), + })) + + dispatch( + addKeyRequested({ + ...basePayload, + fields: validFields, + streamEntryId: streamEntryId.trim() || undefined, + }), + ) + break + } + case KEY_TYPES.JSON: + dispatch( + addKeyRequested({ + ...basePayload, + value: value.trim(), + }), + ) + break + } + + onClose() + } + } + + return ( + + + + +
+
+
+ + Add Key + + + + +
+
+
+
+
+
+ + +
+
+
+
+ + setTtl(e.target.value)} + placeholder="Enter TTL, Default: -1 (no expiration)" + type="number" + value={ttl} + /> +
+
+
+
+
+ + setKeyName(e.target.value)} + placeholder="Enter key name" + type="text" + value={keyName} + /> +
+
+ + Key Elements + +
+
+ {keyType === KEY_TYPES.STRING ? ( + + ) : keyType === KEY_TYPES.LIST ? ( + + ) : keyType === KEY_TYPES.HASH ? ( + + ) : keyType === KEY_TYPES.SET ? ( + + ) : keyType === KEY_TYPES.ZSET ? ( + + ) : keyType === KEY_TYPES.STREAM ? ( + + ) : keyType === KEY_TYPES.JSON ? ( + + ) : ( + + Select a key type + + )} +
+
+ {error && ( + + {error} + + )} + +
+ + +
+
+
+
+
+ ) +} diff --git a/apps/frontend/src/components/key-browser/key-details/key-details-hash.tsx b/apps/frontend/src/components/key-browser/key-details/key-details-hash.tsx index e27cae53..b4a2c589 100644 --- a/apps/frontend/src/components/key-browser/key-details/key-details-hash.tsx +++ b/apps/frontend/src/components/key-browser/key-details/key-details-hash.tsx @@ -1,244 +1,244 @@ -import { useState } from "react" -import { Trash, Plus, X } from "lucide-react" -import { CustomTooltip } from "../../ui/tooltip" -import { Button } from "../../ui/button" -import { Input } from "../../ui/input" -import { EditActionButtons } from "../../ui/edit-action-buttons" -import DeleteModal from "../../ui/delete-modal" -import { Typography } from "../../ui/typography" -import { useAppDispatch } from "@/hooks/hooks" -import { updateKeyRequested } from "@/state/valkey-features/keys/keyBrowserSlice" -import { cn } from "@/lib/utils" - -interface ElementInfo { - key: string; - value: string; -} - -interface KeyDetailsHashProps { - selectedKey: string; - selectedKeyInfo: { - name: string; - type: "hash"; - ttl: number; - size: number; - collectionSize?: number; - elements: ElementInfo[]; - }; - connectionId: string; - readOnly:boolean; -} - -interface NewField { - tempId: string; - key: string; - value: string; -} - -export default function KeyDetailsHash( - { selectedKey, selectedKeyInfo, connectionId, readOnly = false }: KeyDetailsHashProps, -) { - const dispatch = useAppDispatch() - const [isEditable, setIsEditable] = useState(false) - const [editedHashValue, setEditedHashValue] = useState<{ [key: string]: string }>({}) - const [deletedHashFields, setDeletedHashFields] = useState>(new Set()) - const [pendingDeleteField, setPendingDeleteField] = useState(null) - const [newFields, setNewFields] = useState([]) - - const handleEdit = () => { - if (isEditable) { - // Cancel edit - setIsEditable(false) - setEditedHashValue({}) - setDeletedHashFields(new Set()) - setNewFields([]) - } else { - // Start editing - const initialHashValue: { [key: string]: string } = {} - selectedKeyInfo.elements.forEach((element: ElementInfo) => { - initialHashValue[element.key] = element.value - }) - setEditedHashValue(initialHashValue) - setDeletedHashFields(new Set()) - setNewFields([]) - setIsEditable(true) - } - } - - const handleSave = () => { - // combine existing edited fields and new fields - const existingFields = Object.entries(editedHashValue) - .filter(([field]) => !deletedHashFields.has(field)) - .map(([field, value]) => ({ - field, - value, - })) - - const newFieldsToAdd = newFields - // to ensure no empty keys are added - .filter((nf) => nf.key.trim() !== "") - .map((nf) => ({ - field: nf.key, - value: nf.value, - })) - - dispatch(updateKeyRequested({ - connectionId: connectionId, - key: selectedKey, - keyType: "hash", - fields: [...existingFields, ...newFieldsToAdd], - deletedHashFields: Array.from(deletedHashFields), - })) - setIsEditable(false) - setEditedHashValue({}) - setDeletedHashFields(new Set()) - setNewFields([]) - } - - const handleHashFieldChange = (fieldKey: string, newValue: string) => { - setEditedHashValue((prev) => ({ - ...prev, - [fieldKey]: newValue, - })) - } - - const handleDeleteHashField = (fieldKey: string) => { - setPendingDeleteField(fieldKey) - } - - const confirmDeleteHashField = () => { - if (pendingDeleteField) { - setDeletedHashFields((prev) => new Set(prev).add(pendingDeleteField)) - setPendingDeleteField(null) - } - } - - const cancelDeleteHashField = () => { - setPendingDeleteField(null) - } - - const handleAddNewField = () => { - const tempId = `new-${Date.now()}` - setNewFields((prev) => [...prev, { tempId, key: "", value: "" }]) - } - - const handleNewFieldKeyChange = (tempId: string, newKey: string) => { - setNewFields((prev) => - prev.map((field) => - field.tempId === tempId ? { ...field, key: newKey } : field, - ), - ) - } - - const handleNewFieldValueChange = (tempId: string, newValue: string) => { - setNewFields((prev) => - prev.map((field) => - field.tempId === tempId ? { ...field, value: newValue } : field, - ), - ) - } - - const handleRemoveNewField = (tempId: string) => { - setNewFields((prev) => prev.filter((field) => field.tempId !== tempId)) - } - - return ( -
-
-
- Key - Value -
- -
-
- {selectedKeyInfo.elements - .filter((element: ElementInfo) => !deletedHashFields.has(element.key)) - .map((element: ElementInfo, index: number) => ( -
- {element.key} -
- {isEditable ? ( -
- handleHashFieldChange(element.key, e.target.value)} - type="text" - value={editedHashValue[element.key] || ""} - /> - - - - {pendingDeleteField === element.key && ( - - )} -
- ) : ( - {element.value} - )} -
-
- ))} - {isEditable && newFields.map((newField) => ( -
-
- handleNewFieldKeyChange(newField.tempId, e.target.value)} - placeholder="Enter key" - type="text" - value={newField.key} - /> -
-
-
- handleNewFieldValueChange(newField.tempId, e.target.value)} - placeholder="Enter value" - type="text" - value={newField.value} - /> - - - -
-
-
- ))} - {isEditable && ( -
- -
- )} -
-
- ) -} +import { useState } from "react" +import { Trash, Plus, X } from "lucide-react" +import { CustomTooltip } from "../../ui/tooltip" +import { Button } from "../../ui/button" +import { Input } from "../../ui/input" +import { EditActionButtons } from "../../ui/edit-action-buttons" +import DeleteModal from "../../ui/delete-modal" +import { Typography } from "../../ui/typography" +import { useAppDispatch } from "@/hooks/hooks" +import { updateKeyRequested } from "@/state/valkey-features/keys/keyBrowserSlice" +import { cn } from "@/lib/utils" + +interface ElementInfo { + key: string; + value: string; +} + +interface KeyDetailsHashProps { + selectedKey: string; + selectedKeyInfo: { + name: string; + type: "hash"; + ttl: number; + size: number; + collectionSize?: number; + elements: ElementInfo[]; + }; + connectionId: string; + readOnly:boolean; +} + +interface NewField { + tempId: string; + key: string; + value: string; +} + +export default function KeyDetailsHash( + { selectedKey, selectedKeyInfo, connectionId, readOnly = false }: KeyDetailsHashProps, +) { + const dispatch = useAppDispatch() + const [isEditable, setIsEditable] = useState(false) + const [editedHashValue, setEditedHashValue] = useState<{ [key: string]: string }>({}) + const [deletedHashFields, setDeletedHashFields] = useState>(new Set()) + const [pendingDeleteField, setPendingDeleteField] = useState(null) + const [newFields, setNewFields] = useState([]) + + const handleEdit = () => { + if (isEditable) { + // Cancel edit + setIsEditable(false) + setEditedHashValue({}) + setDeletedHashFields(new Set()) + setNewFields([]) + } else { + // Start editing + const initialHashValue: { [key: string]: string } = {} + selectedKeyInfo.elements.forEach((element: ElementInfo) => { + initialHashValue[element.key] = element.value + }) + setEditedHashValue(initialHashValue) + setDeletedHashFields(new Set()) + setNewFields([]) + setIsEditable(true) + } + } + + const handleSave = () => { + // combine existing edited fields and new fields + const existingFields = Object.entries(editedHashValue) + .filter(([field]) => !deletedHashFields.has(field)) + .map(([field, value]) => ({ + field, + value, + })) + + const newFieldsToAdd = newFields + // to ensure no empty keys are added + .filter((nf) => nf.key.trim() !== "") + .map((nf) => ({ + field: nf.key, + value: nf.value, + })) + + dispatch(updateKeyRequested({ + connectionId: connectionId, + key: selectedKey, + keyType: "hash", + fields: [...existingFields, ...newFieldsToAdd], + deletedHashFields: Array.from(deletedHashFields), + })) + setIsEditable(false) + setEditedHashValue({}) + setDeletedHashFields(new Set()) + setNewFields([]) + } + + const handleHashFieldChange = (fieldKey: string, newValue: string) => { + setEditedHashValue((prev) => ({ + ...prev, + [fieldKey]: newValue, + })) + } + + const handleDeleteHashField = (fieldKey: string) => { + setPendingDeleteField(fieldKey) + } + + const confirmDeleteHashField = () => { + if (pendingDeleteField) { + setDeletedHashFields((prev) => new Set(prev).add(pendingDeleteField)) + setPendingDeleteField(null) + } + } + + const cancelDeleteHashField = () => { + setPendingDeleteField(null) + } + + const handleAddNewField = () => { + const tempId = `new-${Date.now()}` + setNewFields((prev) => [...prev, { tempId, key: "", value: "" }]) + } + + const handleNewFieldKeyChange = (tempId: string, newKey: string) => { + setNewFields((prev) => + prev.map((field) => + field.tempId === tempId ? { ...field, key: newKey } : field, + ), + ) + } + + const handleNewFieldValueChange = (tempId: string, newValue: string) => { + setNewFields((prev) => + prev.map((field) => + field.tempId === tempId ? { ...field, value: newValue } : field, + ), + ) + } + + const handleRemoveNewField = (tempId: string) => { + setNewFields((prev) => prev.filter((field) => field.tempId !== tempId)) + } + + return ( +
+
+
+ Key + Value +
+ +
+
+ {selectedKeyInfo.elements + .filter((element: ElementInfo) => !deletedHashFields.has(element.key)) + .map((element: ElementInfo, index: number) => ( +
+ {element.key} +
+ {isEditable ? ( +
+ handleHashFieldChange(element.key, e.target.value)} + type="text" + value={editedHashValue[element.key] || ""} + /> + + + + {pendingDeleteField === element.key && ( + + )} +
+ ) : ( + {element.value} + )} +
+
+ ))} + {isEditable && newFields.map((newField) => ( +
+
+ handleNewFieldKeyChange(newField.tempId, e.target.value)} + placeholder="Enter key" + type="text" + value={newField.key} + /> +
+
+
+ handleNewFieldValueChange(newField.tempId, e.target.value)} + placeholder="Enter value" + type="text" + value={newField.value} + /> + + + +
+
+
+ ))} + {isEditable && ( +
+ +
+ )} +
+
+ ) +} diff --git a/apps/frontend/src/components/key-browser/key-details/key-details-json.tsx b/apps/frontend/src/components/key-browser/key-details/key-details-json.tsx index e91b5b45..96a8aa03 100644 --- a/apps/frontend/src/components/key-browser/key-details/key-details-json.tsx +++ b/apps/frontend/src/components/key-browser/key-details/key-details-json.tsx @@ -1,138 +1,138 @@ -import { useState } from "react" -import { TriangleAlert } from "lucide-react" -import { KEY_TYPES } from "@common/src/constants" -import { useSelector } from "react-redux" -import { EditActionButtons } from "../../ui/edit-action-buttons" -import { Textarea } from "../../ui/textarea" -import { Typography } from "../../ui/typography" -import { useAppDispatch } from "@/hooks/hooks" -import { updateKeyRequested } from "@/state/valkey-features/keys/keyBrowserSlice" -import { selectJsonModuleAvailable } from "@/state/valkey-features/connection/connectionSelectors" -import { cn } from "@/lib/utils" - -interface KeyDetailsJsonProps { - selectedKey: string; - selectedKeyInfo: { - name: string; - type: "ReJSON-RL"; - ttl: number; - size: number; - elements: string; - }; - connectionId: string; - readOnly: boolean; -} - -export default function KeyDetailsJson( - { selectedKey, selectedKeyInfo, connectionId, readOnly = false }: KeyDetailsJsonProps, -) { - const dispatch = useAppDispatch() - const [isEditable, setIsEditable] = useState(false) - const [editedValue, setEditedValue] = useState("") - const [error, setError] = useState("") - - const jsonModuleAvailable = useSelector(selectJsonModuleAvailable(connectionId)) - - let formattedJson = selectedKeyInfo.elements - - try { - const parsed = JSON.parse(selectedKeyInfo.elements) - formattedJson = JSON.stringify(parsed, null, 2) - } catch { - // display as is if not valid JSON - formattedJson = selectedKeyInfo.elements - } - - const handleEdit = () => { - if (isEditable) { - // Cancel edit - setIsEditable(false) - setEditedValue("") - setError("") - } else { - // Start editing with formatted JSON - setEditedValue(formattedJson) - setIsEditable(true) - } - } - - const handleSave = () => { - // validate JSON before saving - try { - JSON.parse(editedValue) - setError("") - dispatch(updateKeyRequested({ - connectionId: connectionId, - key: selectedKey, - keyType: KEY_TYPES.JSON, - value: editedValue, - })) - setIsEditable(false) - setEditedValue("") - } catch { - setError("Invalid JSON format. Please enter valid JSON data.") - } - } - - return ( -
- - - - - - - - - {!jsonModuleAvailable && ( - - - - )} - -
- JSON Value - - -
-
- - - JSON module is not loaded on this Valkey instance. Editing is disabled. - -
-
- {isEditable ? ( -
-