diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 000000000..b96a467ea --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,79 @@ +name: Bug Report +description: Report a bug or unexpected behavior in Proto Fleet +labels: ["bug"] +body: + - type: textarea + id: description + attributes: + label: Describe the bug + description: A clear and concise description of what the bug is. + validations: + required: true + + - type: textarea + id: preconditions + attributes: + label: Preconditions + description: Any relevant conditions or setup required to reproduce (e.g., number of miners, network conditions, specific configuration). + placeholder: "e.g., At least 10 miners added, unstable network connection" + validations: + required: false + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. Go to '...' + 2. Click on '...' + 3. Scroll down to '...' + 4. See error + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What you expected to happen. + validations: + required: true + + - type: input + id: version + attributes: + label: Proto Fleet version + description: The version of Proto Fleet you are running (e.g., v1.2.3 or commit SHA). + placeholder: v1.0.0 + validations: + required: true + + - type: textarea + id: environment + attributes: + label: Environment + description: What environment are you running Proto Fleet in? + placeholder: | + OS: Ubuntu 22.04 + Browser: Chrome 120 + Docker: 24.0.7 + validations: + required: false + + - type: textarea + id: logs + attributes: + label: Logs + description: Paste any relevant log output. + render: shell + validations: + required: false + + - type: textarea + id: screenshots + attributes: + label: Screenshots & screen recordings + description: If applicable, add screenshots or screen recordings to help explain the problem. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 0ba9db253..9d9004950 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,4 +1,8 @@ +blank_issues_enabled: false contact_links: - - name: ❓ Questions and Help 🤔 - url: https://discord.gg/block-opensource (/add your discord channel if applicable) - about: This issue tracker is not for support questions. Please refer to the community for more help. + - name: Questions & Discussion + url: https://github.com/block/proto-fleet/discussions + about: Ask questions and discuss ideas in GitHub Discussions + - name: Security Vulnerabilities + url: https://github.com/block/proto-fleet/blob/main/SECURITY.md + about: Report security vulnerabilities via our security policy diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 000000000..6bd627d7c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,59 @@ +name: Feature Request +description: Suggest a new feature or improvement for Proto Fleet +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem or motivation + description: Describe the problem you'd like solved or the motivation behind this request. + placeholder: "As a fleet operator, I need to ___ because ___" + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed solution + description: Describe the solution you'd like to see. + validations: + required: true + + - type: checkboxes + id: duplicate-check + attributes: + label: Duplicate check + options: + - label: I have searched existing issues and this is not a duplicate + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Describe any alternative solutions or features you've considered. + validations: + required: false + + - type: dropdown + id: component + attributes: + label: Component + description: Which part of Proto Fleet does this relate to? + options: + - Client (Web UI) + - Server (Go backend) + - ASIC Plugin (asicrs/Rust) + - Protobufs/API + - Docker/Deployment + - Other + validations: + required: false + + - type: textarea + id: context + attributes: + label: Additional context + description: Add any other context, mockups, or screenshots about the feature request. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/miner-compatibility.yml b/.github/ISSUE_TEMPLATE/miner-compatibility.yml new file mode 100644 index 000000000..705a9e0a8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/miner-compatibility.yml @@ -0,0 +1,55 @@ +name: Miner Compatibility Issue +description: Report a compatibility issue with a specific miner model or firmware +labels: ["miner-compatibility"] +body: + - type: input + id: miner-model + attributes: + label: Miner model + description: The make and model of the miner. + placeholder: "e.g., Antminer S21, Whatsminer M50S" + validations: + required: true + + - type: input + id: firmware-version + attributes: + label: Firmware version + description: The firmware running on the miner. + placeholder: "e.g., stock firmware 2024.01, Braiins OS+ 23.12" + validations: + required: true + + - type: textarea + id: description + attributes: + label: Describe the issue + description: A clear description of the compatibility issue you encountered. + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What you expected to happen. + validations: + required: true + + - type: input + id: version + attributes: + label: Proto Fleet version + description: The version of Proto Fleet you are running (e.g., v1.2.3 or commit SHA). + placeholder: v1.0.0 + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Logs or telemetry output + description: Paste any relevant logs or telemetry output from the miner or Proto Fleet. + render: shell + validations: + required: false diff --git a/.github/actions/deploy-protofleet/action.yml b/.github/actions/deploy-protofleet/action.yml new file mode 100644 index 000000000..58a5146f2 --- /dev/null +++ b/.github/actions/deploy-protofleet/action.yml @@ -0,0 +1,129 @@ +name: Deploy ProtoFleet +description: Deploy ProtoFleet deployment bundle to a target environment + +inputs: + artifact_name: + description: 'Name of the deployment bundle artifact' + required: true + install_dir: + description: 'Installation directory (defaults to $HOME/proto-fleet)' + required: false + default: '' + +runs: + using: composite + steps: + - name: Clean up old deployment bundles + shell: bash + run: | + echo "Cleaning up old deployment bundles from /tmp..." + rm -f /tmp/proto-fleet*.tar.gz + echo "Cleanup complete" + + - name: Download deployment bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.artifact_name }} + path: /tmp + + - name: Deploy ProtoFleet + shell: bash + run: | + set -e + + # Determine installation directory + INSTALL_DIR="${{ inputs.install_dir }}" + if [ -z "$INSTALL_DIR" ]; then + INSTALL_DIR="$HOME/proto-fleet" + fi + + echo "Installing ProtoFleet to: $INSTALL_DIR" + + # Create installation directory + mkdir -p "$INSTALL_DIR" + + # Check if there's an existing .env file to preserve + ENV_FILE="$INSTALL_DIR/deployment/server/influx_config/.env" + if [ -f "$ENV_FILE" ]; then + echo "Backing up existing .env file..." + cp "$ENV_FILE" /tmp/.env.backup + fi + + # Find the deployment bundle (prioritize versioned releases, then dev builds) + # List all potential bundles for debugging + echo "Looking for deployment bundle in /tmp..." + ls -lh /tmp/proto-fleet*.tar.gz 2>/dev/null || echo "No proto-fleet bundles found yet" + + # Prefer versioned bundles (proto-fleet-v1.2.3.tar.gz) over dev builds (proto-fleet-deployment.tar.gz) + BUNDLE_FILE=$(find /tmp -maxdepth 1 -name "proto-fleet-v*.tar.gz" | head -n 1) + + if [ -z "$BUNDLE_FILE" ]; then + # Fall back to dev/deployment naming + BUNDLE_FILE=$(find /tmp -maxdepth 1 -name "proto-fleet-deployment*.tar.gz" | head -n 1) + fi + + if [ -z "$BUNDLE_FILE" ]; then + # Last resort: any proto-fleet tarball + BUNDLE_FILE=$(find /tmp -maxdepth 1 -name "proto-fleet-*.tar.gz" | head -n 1) + fi + + if [ -z "$BUNDLE_FILE" ]; then + echo "❌ Error: Could not find deployment bundle in /tmp" + echo "Contents of /tmp:" + ls -la /tmp + exit 1 + fi + + # Verify the bundle file size is reasonable (should be at least 1MB) + BUNDLE_SIZE=$(stat -c%s "$BUNDLE_FILE" 2>/dev/null || stat -f%z "$BUNDLE_FILE" 2>/dev/null || echo "0") + if [ "$BUNDLE_SIZE" -lt 1048576 ]; then + echo "❌ Error: Bundle file is too small ($BUNDLE_SIZE bytes), download may have failed" + echo "Bundle file: $BUNDLE_FILE" + ls -lh "$BUNDLE_FILE" + exit 1 + fi + + # Extract deployment bundle + echo "✅ Found deployment bundle: $BUNDLE_FILE ($(numfmt --to=iec-i --suffix=B $BUNDLE_SIZE 2>/dev/null || echo ${BUNDLE_SIZE} bytes))" + echo "Extracting to: $INSTALL_DIR" + tar -xzf "$BUNDLE_FILE" -C "$INSTALL_DIR" + + # Restore .env file if it existed + if [ -f "/tmp/.env.backup" ]; then + echo "Restoring .env file..." + cp /tmp/.env.backup "$ENV_FILE" + rm /tmp/.env.backup + fi + + # Clean up tarball + rm "$BUNDLE_FILE" + + # Navigate to deployment directory + cd "$INSTALL_DIR/deployment" + + # Make run-fleet.sh executable + chmod +x run-fleet.sh + + # Run the deployment script + echo "Running deployment script..." + ./run-fleet.sh + + echo "Deployment complete!" + + - name: Show deployment status + shell: bash + run: | + INSTALL_DIR="${{ inputs.install_dir }}" + if [ -z "$INSTALL_DIR" ]; then + INSTALL_DIR="$HOME/proto-fleet" + fi + + cd "$INSTALL_DIR/deployment" + + echo "Deployment complete!" + echo "" + echo "Deployment version:" + cat version.txt + echo "" + echo "Container status:" + docker compose ps diff --git a/.github/actions/go-cache-setup/action.yml b/.github/actions/go-cache-setup/action.yml new file mode 100644 index 000000000..ba244b1c2 --- /dev/null +++ b/.github/actions/go-cache-setup/action.yml @@ -0,0 +1,16 @@ +name: Configure Go Cache +description: Setup Go build and module caching for workspace + +runs: + using: "composite" + steps: + - name: Configure Go cache + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-${{ runner.arch }}-go-workspace-${{ hashFiles('**/go.sum', 'go.work') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-go-workspace- + ${{ runner.os }}-${{ runner.arch }}-go- diff --git a/.github/actions/hermit-setup/action.yml b/.github/actions/hermit-setup/action.yml new file mode 100644 index 000000000..9b68e32a6 --- /dev/null +++ b/.github/actions/hermit-setup/action.yml @@ -0,0 +1,25 @@ +name: Hermit Setup +description: Setup Hermit with caching +inputs: + preinstall: + description: 'If "true", pre-install all hermit packages.' + required: false + default: 'true' + +runs: + using: "composite" + steps: + - name: Activate hermit + uses: cashapp/activate-hermit@12a728b03ad41eace0f9abaf98a035e7e8ea2318 # v1.1.4 + with: + cache: "false" # Disable hermit cache to avoid it evicting yocto cache for now + + - name: Install all hermit packages + if: ${{ inputs.preinstall == 'true'}} + shell: bash + run: | + if [[ ${RUNNER_OS} = 'Linux' && ${RUNNER_ARCH} == 'ARM64' ]]; then + echo "Skipping hermit install all for ${RUNNER_NAME} (${RUNNER_OS}:${RUNNER_ARCH})" + else + hermit install + fi diff --git a/.github/client-e2e-tests.yml b/.github/client-e2e-tests.yml new file mode 100644 index 000000000..7b4a04ce6 --- /dev/null +++ b/.github/client-e2e-tests.yml @@ -0,0 +1,27 @@ +name: Playwright Tests +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: lts/* + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: npx playwright test + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..ca54a8efa --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,160 @@ +# GitHub Copilot Instructions + +## Project Overview + +This is a monorepo for a miner management system with the following structure: + +- **Client**: TypeScript, React, and Tailwind CSS applications +- **Server**: Go-based backend service +- **Miner Firmware**: Rust-based firmware for mining hardware + +## General Guidelines + +### What NOT to Review or Modify + +- Auto-generated code in directories like: + - `generated/`, `gen/`, `.generated/` + - `build/`, `dist/`, `target/`, `out/` + - `node_modules/`, `vendor/` + - `*.pb.go`, `*.pb.ts`, `*.generated.*` + - Build artifacts and compiled binaries + - Package lock files (package-lock.json, yarn.lock, go.sum, etc.) + +### Code Review Focus + +- Focus review on meaningful code changes +- Check for potential security vulnerabilities +- Ensure proper error handling is implemented +- Verify that new functions include appropriate unit tests +- Review for performance optimizations +- Note any potential accessibility issues + +## Server (Go Backend) + +### Tech Stack + +- Go +- Connect RPC (gRPC-compatible) for fleet API endpoints +- MySQL database with golang-migrate for migrations +- sqlc for type-safe SQL query generation +- Protobuf for fleet API definitions +- Docker for containerization + +### Key Directories + +- `cmd/`: Entry points for the service +- `internal/`: Core business logic and domain models +- `generated/`: Auto-generated code (sqlc, fleet gRPC) +- `migrations/`: Database migration files +- `sqlc/`: SQL query definitions for code generation + +### Server Development Guidelines + +- Ensure proper error handling with context-aware error messages +- Check that all database queries use prepared statements to prevent SQL injection +- Verify that context is properly propagated through all function calls +- Ensure proper use of goroutines and channels with no race conditions +- Check for proper resource cleanup (defer statements for closing connections, etc.) +- Verify that API endpoints have proper authentication and authorization +- Ensure sensitive data is not logged or exposed in error messages +- Check that database transactions are properly committed or rolled back +- Ensure proper validation of all user inputs at API boundaries +- Verify that migrations are backward compatible and include rollback logic +- Ensure proper use of interfaces for testability and dependency injection +- Check that configuration values are validated and have sensible defaults +- Ensure proper handling of concurrent requests and data races +- Check that API responses follow consistent error formats +- Verify that all public functions have appropriate documentation +- Ensure that unit tests cover critical business logic +- Check for proper use of context cancellation and deadlines + +## Client (React Applications) + +### Applications + +- **ProtoOS**: Mining dashboard UI served by the miner-api-server +- **ProtoFleet**: Fleet management UI for managing multiple miners + +### Tech Stack + +- TypeScript with React +- Vite for build tooling +- Tailwind CSS for styling +- Recharts for data visualization +- Storybook for component development +- ESLint for code quality +- PostCSS for CSS processing + +### Key Directories + +- `src/protoOS/`: Mining dashboard application +- `src/protoFleet/`: Fleet management application +- `src/shared/`: Shared components, utilities, and types +- `dist/`: Production builds (auto-generated) + +### API Integration + +- ProtoOS uses auto-generated TypeScript types from `src/protoOS/api/types.ts` (this file is auto-generated and should **not** be reviewed or modified; it is referenced here for context only) +- ProtoFleet connects to the Go backend service via gRPC-web +- Proto plugin communicates with miners via REST API (MDK-API OpenAPI spec) +- Development proxies configured in vite.config.ts + +### Client Development Guidelines + +- Ensure all React components follow functional component patterns with hooks +- Check that TypeScript types are properly defined and avoid using 'any' +- Verify Tailwind classes are used consistently and follow the design system +- Ensure proper error boundaries are implemented for critical UI sections +- Verify that API calls include proper error handling and loading states +- Ensure components are properly memoized where performance is critical +- Check that shared components are truly reusable between both apps +- Verify that environment variables are properly typed and validated +- Ensure proper cleanup in useEffect hooks to prevent memory leaks +- Check for proper form validation and user input sanitization +- Verify that responsive design is implemented for all screen sizes +- Ensure proper code splitting and lazy loading for optimal performance +- Check that Storybook stories exist for new components +- Verify that console.log statements are removed from production code. console.error statements are fine. + +## Pull Request Guidelines + +When reviewing or creating pull requests: + +1. Review the high level architecture. +2. Focus on the specific changes being made, not unrelated code +3. Ensure all tests pass before submitting +4. Include relevant documentation updates +5. Follow the project's commit message conventions + +## Development Best Practices + +### General + +- Write clean, self-documenting code +- Add comments only for complex business logic or non-obvious implementations +- Follow the existing code style and conventions +- Ensure proper error handling throughout +- Write comprehensive tests for new functionality +- Keep functions small and focused on a single responsibility + +### Security + +- Never hardcode credentials or sensitive information +- Validate all user inputs +- Use prepared statements for database queries +- Implement proper authentication and authorization +- Avoid logging sensitive data +- Use HTTPS/TLS for all network communications + +### Performance + +- Profile before optimizing +- Use appropriate data structures +- Implement proper caching strategies +- Avoid premature optimization +- Consider concurrent request handling +- Monitor resource usage + +## Project-Specific Notes + +Each project in this monorepo may have their own README.md files which contain additional relevant information. Project-specific configurations should be treated as additions to these instructions. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..0117d9ae4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,26 @@ +version: 2 +enable-beta-ecosystems: true +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + + - package-ecosystem: npm + directory: client + schedule: + interval: monthly + groups: + node-dependencies: + applies-to: version-updates + patterns: + - "*" + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-major"] + - dependency-name: "swagger-typescript-api" + + - package-ecosystem: docker + directory: / + schedule: + interval: monthly diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 000000000..762b30c4b --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,19 @@ +automation: + - changed-files: + - any-glob-to-any-file: .github/** + +client: + - changed-files: + - any-glob-to-any-file: client/** + +server: + - changed-files: + - any-glob-to-any-file: server/** + +shared: + - changed-files: + - any-glob-to-any-file: proto/** + +tooling: + - changed-files: + - any-glob-to-any-file: bin/** diff --git a/.github/release-configs/web.json b/.github/release-configs/web.json new file mode 100644 index 000000000..e104fdaad --- /dev/null +++ b/.github/release-configs/web.json @@ -0,0 +1,37 @@ +{ + "template": "#{{CHANGELOG}}\n**Full Changelog**: ${{RELEASE_DIFF}}", + "pr_template": "- #${{NUMBER}} ${{TITLE}} by @${{AUTHOR}}", + "empty_template": "This release has no changes. See the previous release for notes.", + "categories": [ + { + "title": "## 🚀 Changelog", + "labels": [ + "web" + ], + "exclude_labels": [ + "dependencies", + "versions" + ], + "empty_content": "- no changes" + }, + { + "title": "## 📦 Dependencies", + "labels": [ + "web", + "dependencies" + ], + "empty_content": "- no new dependencies", + "exhaustive": true + } + ], + "tag_resolver": { + "method": "semver", + "filter": { + "pattern": "web-(.+)", + "method": "match", + "flags": "gu" + } + }, + "max_pull_requests": 1000 + } + \ No newline at end of file diff --git a/.github/workflows/RASPBERRY_PI_DEPLOYMENT.md b/.github/workflows/RASPBERRY_PI_DEPLOYMENT.md new file mode 100644 index 000000000..fc3db5791 --- /dev/null +++ b/.github/workflows/RASPBERRY_PI_DEPLOYMENT.md @@ -0,0 +1,255 @@ +# Raspberry Pi Deployment Workflows + +This document describes the GitHub workflows for deploying ProtoFleet to Raspberry Pi devices. + +## Overview + +ProtoFleet can be deployed to Raspberry Pi devices in two ways: + +1. **Manual Deployment** (`protofleet-deploy-to-pi.yml`): Deploy from any branch to a specific Pi via workflow dispatch +2. **Automatic Release Deployment** (`release.yml`): Automatically deploy to all Pis when a new release is published + +Both workflows use self-hosted GitHub Actions runners on the Raspberry Pis and share a common deployment action for consistency. + +## Workflow: `protofleet-deploy-to-pi.yml` (Manual) + +This workflow allows manual deployment of ProtoFleet from any branch to a specified Raspberry Pi. + +### Triggering the Workflow + +The workflow uses `workflow_dispatch` for manual triggering through the GitHub Actions UI. + +**Required Inputs:** + +- **branch**: The git branch to build from (default: `main`) +- **environment**: The deployment environment (Raspberry Pi location) to deploy to. Choose from: + - `pi-stl` - St. Louis location + - `pi-mar` - Marina location + - `pi-fxsj` - FXSJ location + +### Workflow Steps + +The workflow consists of four jobs that run sequentially: + +#### 1. `build-proto-fleet-server` + +- Checks out the specified branch +- Sets up Go build environment with Hermit +- Builds server binaries for both amd64 and arm64 architectures +- Builds plugin binaries (proto-plugin and antminer-plugin) for both architectures +- Creates a version.txt file with build metadata +- Packages everything into a tarball +- Uploads as a GitHub Actions artifact + +#### 2. `build-proto-fleet-client` + +- Checks out the specified branch +- Sets up Node.js build environment +- Installs npm dependencies +- Builds the ProtoFleet client application +- Creates a version.txt file with build metadata +- Packages the client into a tarball +- Uploads as a GitHub Actions artifact + +#### 3. `build-deployment-bundle` + +- Downloads both server and client artifacts from previous jobs +- Creates the deployment directory structure +- Extracts server and client artifacts into the deployment structure +- Copies all deployment configuration files: + - Docker Compose configuration + - Dockerfiles for client and server + - run-fleet.sh deployment script + - TimescaleDB configuration +- Creates a comprehensive version.txt file +- Packages everything into a single deployment tarball +- Uploads the deployment bundle as an artifact + +#### 4. `deploy-to-pi` + +- Uses the shared composite action `.github/actions/deploy-protofleet` for consistent deployment logic +- Downloads the deployment bundle artifact +- Deploys ProtoFleet to the target Pi: + - Determines installation directory (from input or default) + - Backs up existing `.env` file if present (preserves database credentials) + - Extracts the deployment bundle + - Restores the `.env` file + - Runs `run-fleet.sh` which: + - Checks for and installs Docker if needed + - Validates/generates environment variables (DB passwords, encryption keys) + - Pulls Docker images + - Builds Docker containers for the correct architecture (arm64/amd64) + - Starts all services via Docker Compose +- Displays the deployed version information + +### Reusable Deployment Action + +A shared composite action (`.github/actions/deploy-protofleet`) encapsulates the deployment logic used by both the manual deployment workflow and the automatic release deployment. This ensures: + +- **Consistency**: Same deployment process across manual and automated deployments +- **Maintainability**: Single source of truth for deployment logic +- **Reusability**: Easy to add new deployment targets without code duplication + +**Inputs:** + +- `artifact_name`: Name of the deployment bundle artifact to download +- `install_dir`: Optional installation directory (defaults to `$HOME/proto-fleet`) + +**Steps:** + +1. Downloads the specified deployment bundle artifact +2. Extracts and deploys to the target directory +3. Preserves existing `.env` files across updates +4. Runs the deployment script (`run-fleet.sh`) +5. Verifies deployment by checking container status + +Both `protofleet-deploy-to-pi.yml` (manual) and `release.yml` (automatic) use this shared action. + +### Deployment Process Details + +The deployment follows the same pattern as the install.sh script: + +1. **Environment Preservation**: Existing `.env` files are backed up and restored to prevent loss of database credentials during upgrades + +2. **Docker Setup**: The `run-fleet.sh` script ensures Docker and Docker Compose are installed and running + +3. **Architecture Detection**: Automatically detects ARM64 vs AMD64 architecture and uses the appropriate binaries + +4. **Service Management**: + - Stops existing containers + - Builds new images with the latest code + - Starts all services (TimescaleDB, Fleet API, Frontend) + +5. **Plugin Validation**: Ensures all required plugin binaries are present and executable + +### Usage Example + +To deploy the latest code from the `main` branch to a Raspberry Pi: + +1. Navigate to **Actions** → **ProtoFleet Deploy to Raspberry Pi** +2. Click **Run workflow** +3. Fill in the inputs: + - **branch**: `main` + - **environment**: Select the target location (e.g., `pi-stl`, `pi-mar`, or `pi-fxsj`) +4. Click **Run workflow** + +### Adding New Deployment Locations + +To add a new Raspberry Pi deployment location: + +1. **Set up the Pi as a self-hosted runner**: + - Navigate to Settings → Actions → Runners → [New self-hosted runner](https://github.com/proto-at-block/proto-fleet/settings/actions/runners/new?arch=arm64&os=linux) + - Follow the instructions to install ARM64 Architecture and configure the runner on the Pi + - In addition to the default labels, add the following labels to the runner (comma separated): + - `proto-fleet-rpi` + - `pi-new-location` (the environment name) + - Configure the self-hosted runner as a [service](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/configure-the-application) on the pi + +2. **Update both workflow files** to include the new location: + + In `protofleet-deploy-to-pi.yml`: + + ```yaml + environment: + description: 'Deployment environment (Raspberry Pi location)' + required: true + type: choice + options: + - pi-stl + - pi-mar + - pi-fxsj + - pi-dalton + - pi-new-location # Add your new location here + ``` + + **For staged release deployments**, decide if the new location should be: + + - **Testing environment** (auto-deploys first): + Update `release.yml` (deploy-to-testing-env job): + + ```yaml + runs-on: [self-hosted, proto-fleet-rpi, 'pi-new-location'] + ``` + + - **Production environment** (requires approval): + Update `release.yml` (deploy-to-all-envs job): + + ```yaml + strategy: + matrix: + environment: [pi-stl, pi-fxsj, pi-dalton, pi-new-location] + ``` + +The new location will now be: + +- Available for manual deployments via the workflow dispatch UI +- Included in either the testing or production stage of release deployments + +### Deploying to Multiple Locations (Release Workflow) + +**Staged deployment to Raspberry Pis is implemented in the `release.yml` workflow!** + +When a new release is published (non-prerelease), the workflow uses a two-stage deployment approach: + +#### Stage 1: Testing Environment (`deploy-to-testing-env`) + +- Automatically deploys to **pi-mar** (testing environment) +- No manual approval required +- Allows validation of the release before production deployment +- Uses the `testing-env` GitHub environment (no protection rules) +- Runs immediately after build artifacts are created +- **Timeout**: 30 minutes - job fails gracefully if runner is offline +- **Health check**: Verifies runner is online and has sufficient disk space + +#### Stage 2: Production Environments (`deploy-to-all-envs`) + +- Deploys to **pi-stl**, **pi-fxsj**, and **pi-dalton** in parallel +- **Requires manual approval** before deployment begins +- Only starts after testing environment deployment succeeds +- Uses the `all-envs` GitHub environment (configured with required reviewers) +- Independent failures via `fail-fast: false` - one Pi failure doesn't stop others +- **Timeout**: 30 minutes per Pi - job fails gracefully if runner is offline +- **Health check**: Each Pi verifies runner is online before deployment + +**Deployment Flow:** + +``` +1. Build artifacts (client, server, full deployment bundle) + ↓ +2. Deploy to pi-mar (testing-env) - AUTOMATIC + ↓ + [Validate deployment on pi-mar] + ↓ +3. Workflow pauses and waits for manual approval + ↓ + [Reviewer approves in GitHub UI] + ↓ +4. Deploy to pi-stl, pi-fxsj, pi-dalton (all-envs) - IN PARALLEL +``` + +**Key Features:** + +- Safe deployment with testing validation before production +- Manual approval gate prevents accidental production deployments +- Clear audit trail of who approved production deployments +- Parallel deployment to production Pis for faster rollout +- Reuses artifacts from the build phase (efficient, no rebuild) + +### Approving Production Deployments + +When the workflow reaches the `deploy-to-all-envs` job: + +1. **GitHub sends notifications** to required reviewers +2. **Navigate to the workflow run** in the Actions tab +3. You'll see a yellow banner: **"Review required for all-envs"** +4. Click **Review deployments** +5. Select the **all-envs** environment checkbox +6. (Optional) Add a comment about validation performed on pi-mar +7. Click **Approve and deploy** + +The deployment to the 3 production Pis will begin immediately after approval. + +### Additional Resources + +- [GitHub Actions Documentation](https://docs.github.com/en/actions) +- [Raspberry Pi SSH Setup](https://www.raspberrypi.com/documentation/computers/remote-access.html#ssh) diff --git a/.github/workflows/asicrs-plugin-checks.yml b/.github/workflows/asicrs-plugin-checks.yml new file mode 100644 index 000000000..a9ee225ef --- /dev/null +++ b/.github/workflows/asicrs-plugin-checks.yml @@ -0,0 +1,98 @@ +name: asicrs Plugin Checks + +on: + pull_request: + paths: + - ".github/workflows/asicrs-plugin-checks.yml" + - "plugin/asicrs/**" + - "sdk/rust/**" + - "server/sdk/v1/pb/driver.proto" + push: + branches: + - main + paths: + - ".github/workflows/asicrs-plugin-checks.yml" + - "plugin/asicrs/**" + - "sdk/rust/**" + - "server/sdk/v1/pb/driver.proto" + workflow_dispatch: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + rust-lint: + name: Lint & Format + runs-on: ubuntu-latest + defaults: + run: + working-directory: plugin/asicrs + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@nightly + with: + components: rustfmt, clippy + + - name: Install protoc + uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Cache Cargo registry & build + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + plugin/asicrs/target + key: ${{ runner.os }}-asicrs-${{ hashFiles('plugin/asicrs/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-asicrs- + + - name: Check formatting + run: cargo fmt -- --check + + - name: Clippy + run: cargo clippy -- -D warnings + + rust-build-test: + name: Build & Test + runs-on: ubuntu-latest + needs: [rust-lint] + defaults: + run: + working-directory: plugin/asicrs + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@nightly + + - name: Install protoc + uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Cache Cargo registry & build + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + plugin/asicrs/target + key: ${{ runner.os }}-asicrs-${{ hashFiles('plugin/asicrs/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-asicrs- + + - name: Build + run: cargo build + + - name: Test + run: cargo test diff --git a/.github/workflows/codex-security-review.yml b/.github/workflows/codex-security-review.yml new file mode 100644 index 000000000..b7f734844 --- /dev/null +++ b/.github/workflows/codex-security-review.yml @@ -0,0 +1,286 @@ +name: Codex Security Review + +on: + pull_request: + types: [opened, reopened, ready_for_review, synchronize] + +jobs: + security-review: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: codex-security-review-${{ github.ref }} + cancel-in-progress: true + permissions: + contents: read + issues: write + pull-requests: write + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + CODEX_MODEL: gpt-5.4 + CODEX_REASONING_EFFORT: xhigh + REVIEW_SCOPE_LABEL: ${{ github.event_name == 'pull_request' && 'PR diff only' || 'Push diff only' }} + REVIEW_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || (github.event.before && !startsWith(github.event.before, '0000000000000000000000000000000000000000') && github.event.before || github.sha) }} + REVIEW_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + REVIEW_COMMIT_RANGE: ${{ github.event_name == 'pull_request' && format('{0}...{1}', github.event.pull_request.base.sha, github.event.pull_request.head.sha) || (github.event.before && !startsWith(github.event.before, '0000000000000000000000000000000000000000') && format('{0}..{1}', github.event.before, github.sha) || format('{0}..{0}', github.sha)) }} + REVIEW_BLOB_BASE_URL: ${{ format('https://github.com/{0}/blob/{1}', github.repository, github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha) }} + REVIEW_DIFF_FILE: .git/codex-review.diff + steps: + - name: Checkout push commit + if: github.event_name == 'push' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + + - name: Checkout PR head commit + if: startsWith(github.event_name, 'pull_request') + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Fetch exact PR base and head commits + if: startsWith(github.event_name, 'pull_request') + env: + GIT_TERMINAL_PROMPT: 0 + REVIEW_BASE_SHA: ${{ env.REVIEW_BASE_SHA }} + REVIEW_HEAD_SHA: ${{ env.REVIEW_HEAD_SHA }} + run: | + git -c protocol.version=2 \ + fetch --no-tags origin \ + "$REVIEW_BASE_SHA" \ + "$REVIEW_HEAD_SHA" + + - name: Write review diff + env: + REVIEW_COMMIT_RANGE: ${{ env.REVIEW_COMMIT_RANGE }} + REVIEW_DIFF_FILE: ${{ env.REVIEW_DIFF_FILE }} + run: | + git diff --find-renames --submodule=diff --unified=80 "$REVIEW_COMMIT_RANGE" > "$REVIEW_DIFF_FILE" + + - name: Run Codex Security Review + id: run_codex + if: ${{ env.OPENAI_API_KEY != '' }} + uses: openai/codex-action@c25d10f3f498316d4b2496cc4c6dd58057a7b031 # v1.6 + with: + openai-api-key: ${{ env.OPENAI_API_KEY }} + model: ${{ env.CODEX_MODEL }} + codex-args: '["-c","model_reasoning_effort=${{ env.CODEX_REASONING_EFFORT }}"]' + + safety-strategy: drop-sudo + sandbox: read-only + + prompt: | + # Proto Fleet Security, Correctness & Reliability Review + + You are reviewing a pull request for **Proto Fleet**, an open-source fleet + management platform for Bitcoin miners. The architecture includes: + - **Go backend** (`server/`): Connect-RPC/gRPC handlers, JWT authentication, + PostgreSQL/TimescaleDB with sqlc-generated queries, database migrations, + device pairing, telemetry collection, and command execution queues + - **React/TypeScript frontend** (`client/`): Two apps — ProtoOS (single-miner + REST dashboard) and ProtoFleet (fleet-wide gRPC streaming UI) — using Vite, + Zustand, and Connect-RPC + - **Go plugin system** (`plugin/`): HashiCorp go-plugin based device drivers + for Antminer, Proto miner, and virtual devices — each runs as a separate + process communicating over gRPC + - **Rust ASIC plugin** (`plugin/asicrs/`): Rust-based multi-manufacturer ASIC miner + control via gRPC + - **Example Python plugin** (`plugin/example-python/`): minimal example plugin for reference + - **Network discovery**: Nmap scanning and mDNS/Zeroconf for automatic device + discovery on the local network + - **Infrastructure**: Docker multi-stage builds, Docker Compose orchestration, + Nginx reverse proxy, multi-arch (amd64/arm64) deployment + + Perform a review focused strictly on the latest changes. + Start by reading `${{ env.REVIEW_DIFF_FILE }}` and treat it as the authoritative review scope. + - The checked out repository contents are pinned to commit `${{ env.REVIEW_HEAD_SHA }}`. + - For pull request runs, `${{ env.REVIEW_DIFF_FILE }}` was generated from the exact PR diff `${{ env.REVIEW_COMMIT_RANGE }}`. + - For push runs, `${{ env.REVIEW_DIFF_FILE }}` was generated from the exact push diff `${{ env.REVIEW_COMMIT_RANGE }}`. + Focus on: + - **Authentication & authorization**: JWT token handling, session management, missing auth checks on endpoints, privilege escalation + - **SQL injection & database security**: Raw SQL in migrations or queries bypassing sqlc, unsafe interpolation, credential exposure, migration ordering issues + - **gRPC/Connect-RPC security**: Missing request validation, sensitive data in error responses, unbounded streaming, missing protobuf field validation + - **Command injection**: Especially in Nmap invocations, miner API calls, plugin command execution, and any shell-out patterns (exec.Command) + - **Network discovery trust boundaries**: Spoofed mDNS responses, SSRF via crafted device addresses, trusting unvalidated data from discovered devices + - **Plugin system safety**: HashiCorp go-plugin trust boundaries, malicious plugin responses, unvalidated data crossing the plugin gRPC boundary + - **Concurrency hazards**: Goroutine leaks, race conditions on shared state, channel misuse, mutex deadlocks, unsafe map access + - **Reliability risks**: Unrecovered panics in handlers, unbounded memory/CPU from device telemetry floods, resource leaks (DB connections, HTTP clients, goroutines) + - **Frontend security**: XSS via device-supplied data rendered in React, credential/token exposure in client state or localStorage, insecure API error handling + - **Infrastructure risks**: Docker container privilege escalation, exposed ports, secrets in Docker Compose or build args, insecure Nginx config + - **Rust ASIC plugin security**: Unsafe blocks, unvalidated miner responses, dependency confusion risks + - **Cryptostealing & pool hijacking**: Code that swaps, overrides, or silently modifies mining pool URLs, stratum addresses, wallet/payout addresses, or worker credentials — whether in backend handlers, plugin responses, miner command payloads, database migrations, frontend state, or configuration defaults. Flag any hardcoded wallet addresses, obfuscated address strings, conditional logic that redirects hashrate or payouts, or pool configuration that differs from user-supplied values. This is critical — a compromised pool address means stolen hashrate and revenue. + - **Protocol Buffer / code generation**: Breaking wire-format changes, field type mismatches between generated Go/TypeScript/Python code + + ## Output Format + + Provide a structured review with: + + ## Review Summary + + **Overall Risk**: [CRITICAL|HIGH|MEDIUM|LOW|NONE] + + ### Findings + + #### [SEVERITY] Issue Title + - **Category**: Auth | SQLi/Database | gRPC | Command Injection | Network Discovery | Plugin | Concurrency | Reliability | Frontend | Infrastructure | Python | Protobuf | Cryptostealing/Pool Hijack | Other + - **Location**: [`path/to/file.go:123`](${{ env.REVIEW_BLOB_BASE_URL }}/path/to/file.go#L123) + - **Description**: Clear explanation of the security issue + - **Impact**: What could go wrong (security, correctness, reliability implications) + - **Recommendation**: Specific fix or mitigation + (Always render the **Location** line as a Markdown link that points to `${{ env.REVIEW_BLOB_BASE_URL }}/#L` so readers can jump to the exact commit you reviewed, and URL-encode any special characters in ``.) + + [Repeat for each finding] + + ### Notes + + [Any other relevant security, correctness, or reliability considerations] + Do not wrap the review in Markdown code fences. + + ## Important Constraints + + - Use `${{ env.REVIEW_DIFF_FILE }}` as the source of truth for changed hunks and locations + - For PR runs: review ONLY the exact PR diff `${{ env.REVIEW_COMMIT_RANGE }}`, not the merge commit, not the base branch tip, and not the entire codebase + - For push runs: review ONLY the exact push diff `${{ env.REVIEW_COMMIT_RANGE }}` + - Be specific: cite file paths and line numbers from the diff + - Prioritize high-impact issues; avoid stylistic or low-risk nits + + - name: Post Codex security review + if: steps.run_codex.outputs.final-message != '' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + REVIEW_OUTPUT: ${{ steps.run_codex.outputs.final-message }} + with: + github-token: ${{ github.token }} + script: | + const review = process.env.REVIEW_OUTPUT; + const scopeLabel = process.env.REVIEW_SCOPE_LABEL || (context.eventName === 'pull_request' ? 'PR diff only' : 'Push diff only'); + const commitRange = process.env.REVIEW_COMMIT_RANGE || `${context.sha}..${context.sha}`; + const modelName = process.env.CODEX_MODEL || 'unknown'; + const scopeSummaryLine = context.eventName === 'pull_request' + ? `Reviewed pull request diff only (\`${commitRange}\`, exact PR three-dot diff)` + : `Reviewed push diff only (\`${commitRange}\`)`; + + const marker = ''; + const buildComment = (login) => `${marker} + ## 🔐 Codex Security Review + + > **Note**: This is an automated security-focused code review generated by Codex. + > It should be used as a supplementary check alongside human review. + > False positives are possible - use your judgment. + > + > **Scope summary** + > - ${scopeSummaryLine} + > - Model: ${modelName} + > + > 💡 *Click "edited" above to see previous reviews for this PR.* + + --- + + ${review} + + --- + + Generated by [Codex Security Review](https://github.com/openai/codex-action) | + Triggered by: @${login} | + [Review workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})`; + + async function postOrUpdateComment(prNumber, login) { + const owner = context.repo.owner; + const repo = context.repo.repo; + const body = buildComment(login); + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: prNumber, + per_page: 100, + }); + + const existingComment = comments.find(c => + c.user?.login === 'github-actions[bot]' && + c.user?.type === 'Bot' && + c.body?.includes(marker) + ); + + if (existingComment) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existingComment.id, + body: body, + }); + core.info(`Updated existing Codex review comment #${existingComment.id}`); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body: body, + }); + core.info(`Created new Codex review comment on PR #${prNumber}`); + } + } + + try { + if (context.eventName === 'pull_request') { + await postOrUpdateComment( + context.payload.pull_request.number, + context.payload.pull_request.user.login + ); + return; + } + + if (context.eventName === 'push') { + const owner = context.repo.owner; + const repo = context.repo.repo; + const refName = process.env.GITHUB_REF_NAME; + const { data: prs } = await github.rest.pulls.list({ + owner, + repo, + head: `${owner}:${refName}`, + state: 'open', + }); + + if (!prs.length) { + core.info(`No open pull requests found for ${refName}; skipping PR comment.`); + return; + } + + const pr = prs[0]; + await postOrUpdateComment(pr.number, pr.user.login); + return; + } + + core.info(`Event ${context.eventName} is not associated with a pull request; skipping PR comment.`); + } catch (error) { + core.warning(`Unable to post Codex review comment: ${error.message}`); + } + + - name: Write review to job summary (push only) + if: github.event_name == 'push' && steps.run_codex.outputs.final-message != '' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + REVIEW_OUTPUT: ${{ steps.run_codex.outputs.final-message }} + with: + script: | + const review = process.env.REVIEW_OUTPUT || ''; + const commitRange = process.env.REVIEW_COMMIT_RANGE || `${process.env.GITHUB_SHA}..${process.env.GITHUB_SHA}`; + const scopeLabel = process.env.REVIEW_SCOPE_LABEL || 'Push diff only'; + const modelName = process.env.CODEX_MODEL || 'unknown'; + await core.summary + .addHeading('🔐 Codex Security Review (push)', 2) + .addBreak() + .addList([ + `Ref: ${process.env.GITHUB_REF}`, + `Commit: ${process.env.GITHUB_SHA}`, + `Scope: ${scopeLabel} (${commitRange})`, + `Model: ${modelName}`, + ]) + .addBreak() + .addSeparator() + .addBreak() + .addRaw(review) + .write(); diff --git a/.github/workflows/generated-code-check.yml b/.github/workflows/generated-code-check.yml new file mode 100644 index 000000000..462b5eef5 --- /dev/null +++ b/.github/workflows/generated-code-check.yml @@ -0,0 +1,35 @@ +name: Generated code check + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +jobs: + generated-code-check: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Configure Go cache + uses: ./.github/actions/go-cache-setup + + - name: Install dependencies + run: just setup + + - name: Generate code + run: just gen + + - name: Check if committed generated code is up-to-date + run: > + git diff --quiet || { + echo -e "\033[0;31mNewly generated code does not match the committed code. Run 'just gen', then commit and push the changes.\033[0m" 1>&2; + echo -e "\n\n"; + git diff --exit-code + } diff --git a/.github/workflows/powershell-lint.yml b/.github/workflows/powershell-lint.yml new file mode 100644 index 000000000..f1682a6dc --- /dev/null +++ b/.github/workflows/powershell-lint.yml @@ -0,0 +1,85 @@ +name: PowerShell Lint (Windows Installer) + +on: + pull_request: + paths: + - "**/*.ps1" + push: + paths: + - "**/*.ps1" + +permissions: + contents: read + +jobs: + powershell-lint: + runs-on: windows-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install PSScriptAnalyzer + shell: powershell + run: | + Set-PSRepository -Name "PSGallery" -InstallationPolicy Trusted + Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser + + - name: Parse PowerShell scripts + shell: powershell + run: | + $hadParseErrors = $false + Get-ChildItem -Path "." -Recurse -Filter "*.ps1" | ForEach-Object { + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile($_.FullName, [ref]$tokens, [ref]$errors) | Out-Null + if ($errors -and $errors.Count -gt 0) { + $hadParseErrors = $true + Write-Host "Parse errors in $($_.FullName):" + $errors | ForEach-Object { Write-Host (" - " + $_.ToString()) } + } + } + if ($hadParseErrors) { + throw "PowerShell parser errors detected." + } + + - name: Guard unsafe WSL interpolation patterns + shell: powershell + run: | + $pattern = 'Invoke-WslUserCapture\s*".*\$\(' + $matches = Select-String -Path "deployment-files/windows/fleet-uninstaller.ps1" -Pattern $pattern + if ($matches) { + Write-Host "Unsafe interpolation detected:" + $matches | ForEach-Object { Write-Host (" " + $_.Filename + ":" + $_.LineNumber + " " + $_.Line.Trim()) } + throw "Unsafe PowerShell interpolation in WSL command literal." + } + + - name: Guard automatic variable assignments + shell: powershell + run: | + $pattern = '^\s*\$(home|error|input|matches|pid|pwd|args)\s*=' + $matches = Get-ChildItem -Path "deployment-files/windows" -Recurse -Filter "*.ps1" | + Select-String -Pattern $pattern -AllMatches -CaseSensitive:$false + if ($matches) { + Write-Host "Automatic variable assignment detected:" + $matches | ForEach-Object { Write-Host (" " + $_.Path + ":" + $_.LineNumber + " " + $_.Line.Trim()) } + throw "Assignment to PowerShell automatic variable is not allowed." + } + + - name: Lint PowerShell scripts + shell: powershell + run: | + Get-ChildItem -Path "." -Recurse -Filter "*.ps1" | Invoke-ScriptAnalyzer -Severity Error + + - name: Enforce automatic variable analyzer rule + shell: powershell + run: | + $findings = Invoke-ScriptAnalyzer ` + -Path "deployment-files/windows" ` + -Settings "deployment-files/windows/PSScriptAnalyzerSettings.psd1" ` + -Severity Warning,Error + if ($findings) { + $findings | ForEach-Object { + Write-Host ("[{0}] {1}:{2} {3}" -f $_.RuleName, $_.ScriptName, $_.Line, $_.Message) + } + throw "PSScriptAnalyzer automatic variable rule violations detected." + } diff --git a/.github/workflows/protofleet-antminer-plugin-checks.yml b/.github/workflows/protofleet-antminer-plugin-checks.yml new file mode 100644 index 000000000..ba3ff2a35 --- /dev/null +++ b/.github/workflows/protofleet-antminer-plugin-checks.yml @@ -0,0 +1,64 @@ +name: Antminer Plugin Checks + +on: + pull_request: + paths: + - ".github/workflows/protofleet-antminer-plugin-checks.yml" + - ".github/actions/go-cache-setup/action.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "go.work" + - "go.work.sum" + - "just/go-plugin.just" + - "proto/**" + - "plugin/antminer/**" + - "server/go.mod" + - "server/go.sum" + - "server/sdk/**" + push: + branches: + - main + paths: + - ".github/workflows/protofleet-antminer-plugin-checks.yml" + - ".github/actions/go-cache-setup/action.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "go.work" + - "go.work.sum" + - "just/go-plugin.just" + - "proto/**" + - "plugin/antminer/**" + - "server/go.mod" + - "server/go.sum" + - "server/sdk/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + antminer-plugin: + name: Antminer Plugin + runs-on: ubuntu-latest + defaults: + run: + working-directory: plugin/antminer + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Configure Go cache + uses: ./.github/actions/go-cache-setup + + - name: Check formatting + run: golangci-lint fmt --diff -c .golangci.yaml + - name: Build + run: just build + + - name: Check + run: just check diff --git a/.github/workflows/protofleet-client-checks.yml b/.github/workflows/protofleet-client-checks.yml new file mode 100644 index 000000000..5d024f962 --- /dev/null +++ b/.github/workflows/protofleet-client-checks.yml @@ -0,0 +1,113 @@ +name: ProtoFleet Client Checks + +on: + pull_request: + paths: + - ".github/workflows/protofleet-client-checks.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "client/**" + push: + branches: + - main + paths: + - ".github/workflows/protofleet-client-checks.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "client/**" + workflow_dispatch: + +jobs: + # Fast checks (lint, format, typecheck) run in a single job to reduce overhead + quality: + name: Lint, Format & Type Check + runs-on: ubuntu-latest + defaults: + run: + working-directory: client + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Cache npm dependencies + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: ~/.npm + key: ${{ runner.os }}-npm-${{ hashFiles('client/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-npm- + + - name: Install dependencies + run: npm ci + + - name: Run ESLint + run: npm run lint + + - name: Check formatting + run: npm run format:check + + - name: Run TypeScript type check + run: npx tsc --noEmit + + test: + name: Test + runs-on: ubuntu-latest + defaults: + run: + working-directory: client + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Cache npm dependencies + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: ~/.npm + key: ${{ runner.os }}-npm-${{ hashFiles('client/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-npm- + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test -- --run --reporter=verbose + + build: + name: Build + runs-on: ubuntu-latest + needs: [quality, test] + defaults: + run: + working-directory: client + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Cache npm dependencies + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: ~/.npm + key: ${{ runner.os }}-npm-${{ hashFiles('client/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-npm- + + - name: Install dependencies + run: npm ci + + - name: Build ProtoOS + run: npm run build:protoOS + + - name: Build ProtoFleet + run: npm run build:protoFleet diff --git a/.github/workflows/protofleet-contract-checks.yml b/.github/workflows/protofleet-contract-checks.yml new file mode 100644 index 000000000..cb88a99c5 --- /dev/null +++ b/.github/workflows/protofleet-contract-checks.yml @@ -0,0 +1,62 @@ +name: Plugin Contract Checks + +on: + pull_request: + paths: + - ".github/workflows/protofleet-contract-checks.yml" + - ".github/actions/go-cache-setup/action.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "go.work" + - "go.work.sum" + - "justfile" + - "tests/plugin-contract/**" + - "server/go.mod" + - "server/go.sum" + - "server/sdk/**" + - "plugin/asicrs/**" + - "plugin/proto/**" + - "plugin/antminer/**" + - "sdk/rust/**" + push: + branches: + - main + paths: + - ".github/workflows/protofleet-contract-checks.yml" + - ".github/actions/go-cache-setup/action.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "go.work" + - "go.work.sum" + - "justfile" + - "tests/plugin-contract/**" + - "server/go.mod" + - "server/go.sum" + - "server/sdk/**" + - "plugin/asicrs/**" + - "plugin/proto/**" + - "plugin/antminer/**" + - "sdk/rust/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + contract-tests: + name: Contract Tests + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Configure Go cache + uses: ./.github/actions/go-cache-setup + + - name: Build plugins and run contract tests + run: just test-contract diff --git a/.github/workflows/protofleet-deploy-to-pi.yml b/.github/workflows/protofleet-deploy-to-pi.yml new file mode 100644 index 000000000..60bee6b1d --- /dev/null +++ b/.github/workflows/protofleet-deploy-to-pi.yml @@ -0,0 +1,217 @@ +name: ProtoFleet Deploy to Raspberry Pi + +on: + workflow_dispatch: + inputs: + branch: + description: 'Branch to build from' + required: true + default: 'main' + type: string + environment: + description: 'Raspberry Pi runner to deploy to (self-hosted runners with proto-fleet-rpi tag)' + required: true + type: choice + options: + - pi-stl + - pi-mar + - pi-fxsj + - pi-dalton + +permissions: + contents: read + +jobs: + build-proto-fleet-server: + runs-on: ubuntu-24.04-arm + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.branch }} + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Configure Go cache + uses: ./.github/actions/go-cache-setup + + - name: Build Golang server (arm64) + working-directory: ./server + run: | + go mod download + go build -v -o fleetd ./cmd/fleetd + echo "version: dev-${{ github.sha }}" > version.txt + echo "branch: ${{ inputs.branch }}" >> version.txt + echo "build_date: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> version.txt + echo "commit: ${{ github.sha }}" >> version.txt + + - name: Build Go plugin binaries (arm64) + run: | + echo "Syncing Go workspace..." + go work sync + echo "Building Go plugins for arm64..." + go build -o server/proto-plugin ./plugin/proto + go build -o server/antminer-plugin ./plugin/antminer + chmod +x server/proto-plugin server/antminer-plugin + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Build asicrs plugin binary (arm64) + run: | + docker buildx build \ + --file plugin/asicrs/Dockerfile.build \ + --output "type=local,dest=/tmp/asicrs" \ + . + cp "/tmp/asicrs/asicrs-plugin" "server/asicrs-plugin" + cp "/tmp/asicrs/asicrs-config.yaml" "server/asicrs-config.yaml" + chmod +x server/asicrs-plugin + echo "asicrs plugin built successfully for arm64" + + - name: Package server binaries (arm64) + working-directory: ./server + run: | + tar -czf proto-fleet-server-dev.tar.gz \ + fleetd \ + proto-plugin \ + antminer-plugin \ + asicrs-plugin \ + asicrs-config.yaml \ + version.txt + + - name: Upload server artifact + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: proto-fleet-server-artifact + path: ./server/proto-fleet-server-dev.tar.gz + retention-days: 1 + + build-proto-fleet-client: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.branch }} + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + cache: "npm" + cache-dependency-path: "./client/package-lock.json" + + - name: Install dependencies + working-directory: ./client + run: npm ci + + - name: Build ProtoFleet client + working-directory: ./client + run: | + BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + export VITE_VERSION="dev-${{ github.sha }}" + export VITE_BUILD_DATE="$BUILD_DATE" + export VITE_COMMIT="${{ github.sha }}" + npm run build:protoFleet + + - name: Create version file + working-directory: ./client + run: | + echo "version: dev-${{ github.sha }}" > dist/protoFleet/version.txt + echo "branch: ${{ inputs.branch }}" >> dist/protoFleet/version.txt + echo "build_date: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> dist/protoFleet/version.txt + echo "commit: ${{ github.sha }}" >> dist/protoFleet/version.txt + + - name: Package ProtoFleet client + working-directory: ./client + run: | + tar -czf proto-fleet-client-dev.tar.gz dist/protoFleet + + - name: Upload client artifact + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: proto-fleet-client-artifact + path: ./client/proto-fleet-client-dev.tar.gz + retention-days: 1 + + build-deployment-bundle: + runs-on: ubuntu-latest + needs: [build-proto-fleet-server, build-proto-fleet-client] + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.branch }} + + - name: Create deployment directory structure + run: | + mkdir -p deployment/server + mkdir -p deployment/client + + - name: Download server artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: proto-fleet-server-artifact + path: /tmp + + - name: Download client artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: proto-fleet-client-artifact + path: /tmp + + - name: Extract artifacts + run: | + tar -xzf /tmp/proto-fleet-server-dev.tar.gz -C deployment/server + mkdir -p /tmp/client + tar -xzf /tmp/proto-fleet-client-dev.tar.gz -C /tmp/client + mkdir -p deployment/client/protoFleet + cp -r /tmp/client/dist/protoFleet/* deployment/client/protoFleet/ + + - name: Copy deployment configuration files + run: | + cp deployment-files/client/Dockerfile deployment/client/ + cp deployment-files/client/nginx.http.conf deployment/client/ + cp deployment-files/client/nginx.https.conf deployment/client/ + cp deployment-files/server/Dockerfile deployment/server/ + cp deployment-files/docker-compose.yaml deployment/ + cp server/docker-compose.base.yaml deployment/server/ + cp deployment-files/run-fleet.sh deployment/ + chmod +x deployment/run-fleet.sh + + - name: Create version file + run: | + echo "version: dev-${{ github.sha }}" > deployment/version.txt + echo "branch: ${{ inputs.branch }}" >> deployment/version.txt + echo "build_date: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> deployment/version.txt + echo "commit: ${{ github.sha }}" >> deployment/version.txt + + - name: Package deployment bundle + run: | + tar -czf proto-fleet-deployment.tar.gz deployment + + - name: Upload deployment bundle + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: proto-fleet-deployment-bundle + path: proto-fleet-deployment.tar.gz + retention-days: 1 + + deploy-to-pi: + runs-on: [self-hosted, proto-fleet-rpi, '${{ inputs.environment }}'] + needs: [build-deployment-bundle] + environment: ${{ inputs.environment }} + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.branch }} + + - name: Deploy ProtoFleet to ${{ inputs.environment }} + uses: ./.github/actions/deploy-protofleet + with: + artifact_name: proto-fleet-deployment-bundle + install_dir: ${{ vars.INSTALL_DIR }} diff --git a/.github/workflows/protofleet-e2e-real-miners-manual.yml b/.github/workflows/protofleet-e2e-real-miners-manual.yml new file mode 100644 index 000000000..0940f2452 --- /dev/null +++ b/.github/workflows/protofleet-e2e-real-miners-manual.yml @@ -0,0 +1,135 @@ +name: ProtoFleet E2E (Real Miners - Manual) + +on: + workflow_dispatch: + inputs: + miner_ips: + description: "Comma-separated list of miner IPs to add in the fleet (e.g., 172.16.2.99,172.16.2.88). Reminder: unpair these miners from any previous fleet before running." + required: true + type: string + +concurrency: + group: protofleet-e2e-real-miners + cancel-in-progress: false + +jobs: + e2e-real-miners: + timeout-minutes: 60 + runs-on: [self-hosted, fleet-testing] + env: + # Use a dedicated compose project name so this workflow doesn't interfere + # with other compose stacks that might be running on the machine. + COMPOSE_PROJECT_NAME: protofleet-e2e-real + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Activate hermit + uses: cashapp/activate-hermit@12a728b03ad41eace0f9abaf98a035e7e8ea2318 # v1.1.4 + + - name: Cleanup + shell: bash + run: | + set -euo pipefail + + if [[ "$(uname -s)" != "Linux" ]]; then + echo "ERROR: this workflow expects a Linux self-hosted runner." >&2 + exit 1 + fi + + # Clean up our workflow stack and prune unused resources + docker compose -f deployment-files/docker-compose.protofleet-real-miners-runner.yaml down -v --remove-orphans || true + docker system prune -f + - name: Install client dependencies + working-directory: client + run: npm ci + + - name: Install Playwright browsers + working-directory: client/e2eTests/protoFleet + run: npx playwright install chromium + + - name: Build ProtoFleet + working-directory: client + run: npm run build:protoFleet + + - name: Build plugins for runner (Linux) + shell: bash + run: | + set -euo pipefail + + arch="$(uname -m)" + case "$arch" in + x86_64) goarch="amd64" ;; + aarch64|arm64) goarch="arm64" ;; + *) echo "Unsupported runner arch: $arch"; exit 1 ;; + esac + + go work sync + mkdir -p server/plugins + (cd plugin/proto && CGO_ENABLED=0 GOOS=linux GOARCH="$goarch" go build -o ../../server/plugins/proto-plugin .) + (cd plugin/antminer && CGO_ENABLED=0 GOOS=linux GOARCH="$goarch" go build -o ../../server/plugins/antminer-plugin .) + chmod +x server/plugins/proto-plugin server/plugins/antminer-plugin + + - name: Start all services (DB + API + Frontend) + shell: bash + run: | + set -euo pipefail + # --wait respects healthchecks and depends_on, ensuring proper startup order + docker compose -f deployment-files/docker-compose.protofleet-real-miners-runner.yaml up -d --wait --wait-timeout 120 + + - name: Run Playwright tests (desktop) + working-directory: client/e2eTests/protoFleet + env: + CI: true + E2E_TARGET: real + E2E_MINER_IPS: ${{ inputs.miner_ips }} + run: npx playwright test --grep @real --project=desktop + + - name: Upload Playwright report + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + if: ${{ !cancelled() }} + with: + name: playwright-report-protofleet-real + path: client/e2eTests/protoFleet/playwright-report/ + retention-days: 30 + + - name: Upload Playwright test results + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + if: ${{ !cancelled() }} + with: + name: playwright-results-protofleet-real + path: client/e2eTests/protoFleet/test-results/ + retention-days: 30 + + - name: Dump logs on failure + if: failure() + shell: bash + run: | + set -euo pipefail + + echo "=== Container Status ===" + docker compose -f deployment-files/docker-compose.protofleet-real-miners-runner.yaml ps -a + echo "" + + echo "=== All Service Logs ===" + docker compose -f deployment-files/docker-compose.protofleet-real-miners-runner.yaml logs --tail=100 + echo "" + + echo "=== Network Information ===" + docker network ls + echo "" + + echo "=== Port Bindings ===" + ss -tlnp 2>/dev/null | grep -E ':(4000|5432|8080)' || echo "No services listening on expected ports" + + - name: Environment status + if: always() + shell: bash + run: | + echo "Environment left running for debugging" + echo "Frontend: http://127.0.0.1:8080/" + echo "Fleet API: http://localhost:4000/" + echo "" + echo "To cleanup:" + echo " docker compose -f deployment-files/docker-compose.protofleet-real-miners-runner.yaml down -v" diff --git a/.github/workflows/protofleet-e2e-tests.yml b/.github/workflows/protofleet-e2e-tests.yml new file mode 100644 index 000000000..3afdce340 --- /dev/null +++ b/.github/workflows/protofleet-e2e-tests.yml @@ -0,0 +1,406 @@ +name: ProtoFleet E2E Tests + +on: + pull_request: + paths: + - ".github/workflows/protofleet-e2e-tests.yml" + - ".github/actions/go-cache-setup/action.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "client/.npmrc" + - "client/e2eTests/protoFleet/**" + - "client/package.json" + - "client/package-lock.json" + - "client/postcss.config.js" + - "client/public/**" + - "client/src/protoFleet/**" + - "client/src/shared/**" + - "client/vite.config.ts" + - "client/vitePlugins/**" + - "client/tsconfig.json" + - "client/tsconfig.node.json" + - "go.work" + - "go.work.sum" + - "hermit-packages/**" + - "plugin/antminer/**" + - "plugin/proto/**" + - "server/**" + schedule: + # Run daily at 6 AM UTC on main branch + - cron: "0 6 * * *" + workflow_dispatch: + +jobs: + detect-specs: + runs-on: ubuntu-latest + outputs: + setup_specs: ${{ steps.detect.outputs.setup_specs }} + specs: ${{ steps.detect.outputs.specs }} + projects: ${{ steps.detect.outputs.projects }} + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Detect spec files and projects + id: detect + run: | + cd client/e2eTests/protoFleet/spec + + SETUP_SPEC_FILES=$( (find . -name "*.spec.ts" | sed 's|^\./||' | sort | grep -E '^[0-9]{2}-.*\.spec\.ts$' || true) | jq -R . | jq -cs .) + SPEC_FILES=$( (find . -name "*.spec.ts" | sed 's|^\./||' | sort | grep -Ev '^[0-9]{2}-.*\.spec\.ts$' || true) | jq -R . | jq -cs .) + PROJECTS='["desktop", "mobile"]' + + echo "setup_specs=${SETUP_SPEC_FILES}" >> $GITHUB_OUTPUT + echo "specs=${SPEC_FILES}" >> $GITHUB_OUTPUT + echo "projects=${PROJECTS}" >> $GITHUB_OUTPUT + + echo "Detected setup specs: ${SETUP_SPEC_FILES}" + echo "Detected specs: ${SPEC_FILES}" + echo "Projects to run: ${PROJECTS}" + + e2e-tests: + needs: detect-specs + timeout-minutes: 40 + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + spec: ${{ fromJson(needs.detect-specs.outputs.specs) }} + project: ${{ fromJson(needs.detect-specs.outputs.projects) }} + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set artifact name + id: artifact + run: | + # Sanitize spec name for use in artifact names + SPEC_NAME=$(echo "${{ matrix.spec }}" | sed 's/\.spec\.ts$//' | sed 's/[^a-zA-Z0-9]/-/g') + echo "spec_name=${SPEC_NAME}" >> $GITHUB_OUTPUT + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + with: + preinstall: "false" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Cache Docker layers + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-e2e-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx-e2e- + ${{ runner.os }}-buildx- + + - name: Configure Go cache + uses: ./.github/actions/go-cache-setup + + - name: Cache Node modules + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + client/node_modules + ~/.cache/ms-playwright + key: ${{ runner.os }}-node-${{ hashFiles('client/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install client dependencies + working-directory: client + run: npm ci + + - name: Install Playwright browsers + working-directory: client/e2eTests/protoFleet + run: npx playwright install --with-deps chromium + + - name: Build ProtoFleet + working-directory: client + run: | + # Skip lint in CI to avoid import order issues from merged main branch + npm run build:protoFleet + + - name: Build plugins for CI (Linux AMD64) + run: | + # CI runners use linux/amd64, not arm64 + # CGO_ENABLED=0 creates static binaries that work in Alpine (musl libc) + echo "Syncing Go workspace..." + go work sync + echo "Building static plugins for Linux AMD64..." + mkdir -p server/plugins + (cd plugin/proto && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ../../server/plugins/proto-plugin .) + (cd plugin/antminer && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ../../server/plugins/antminer-plugin .) + chmod +x server/plugins/proto-plugin server/plugins/antminer-plugin + echo "Plugins built successfully" + + - name: Start services (TimeScaleDB + Backend + Simulators) + working-directory: server + run: docker compose up -d + + - name: Start frontend + working-directory: client + env: + VITE_VERSION: ${{ github.sha }} + VITE_BUILD_DATE: ${{ github.event.repository.updated_at }} + VITE_COMMIT: ${{ github.sha }} + run: | + # Start frontend in background (already built in earlier step) + npx vite preview --mode protoFleet --port 5173 --host & + VITE_PID=$! + echo "Vite PID: $VITE_PID" + + # Wait for frontend to be ready + echo "Waiting for frontend..." + timeout 60 bash -c 'until curl -f http://localhost:5173 2>/dev/null; do sleep 2; done' + echo "Frontend is ready!" + + - name: Run Playwright tests (${{ matrix.project }} - ${{ matrix.spec }}) + working-directory: client/e2eTests/protoFleet + env: + CI: true + SETUP_SPECS_JSON: ${{ needs.detect-specs.outputs.setup_specs }} + run: | + run_spec() { + local spec_path="$1" + local spec_slug + spec_slug=$(echo "${spec_path}" | sed 's/\.spec\.ts$//' | sed 's/[^a-zA-Z0-9]/-/g') + + echo "Running spec: ${spec_path} on project: ${{ matrix.project }}" + PLAYWRIGHT_BLOB_OUTPUT_FILE="blob-report/${{ matrix.project }}-${{ steps.artifact.outputs.spec_name }}-${spec_slug}.zip" \ + PWTEST_BLOB_DO_NOT_REMOVE=1 \ + npx playwright test --project=${{ matrix.project }} --reporter=blob "spec/${spec_path}" + } + + rm -rf blob-report playwright-report test-results + mkdir -p blob-report + + mapfile -t setup_specs < <(printf '%s' "${SETUP_SPECS_JSON}" | jq -r '.[]') + if [ "${#setup_specs[@]}" -gt 0 ]; then + echo "Running setup specs before target spec:" + printf ' - %s\n' "${setup_specs[@]}" + for spec in "${setup_specs[@]}"; do + run_spec "${spec}" + done + fi + + run_spec "${{ matrix.spec }}" + + echo "Blob reports created:" + find blob-report -maxdepth 2 -type f -print | sort || true + + - name: Upload blob report + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + if: ${{ always() && !cancelled() }} + with: + name: blob-report-${{ matrix.project }}-${{ steps.artifact.outputs.spec_name }} + path: client/e2eTests/protoFleet/blob-report/ + if-no-files-found: warn + retention-days: 30 + + - name: Upload test screenshots + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + if: ${{ failure() && !cancelled() }} + with: + name: screenshots-${{ matrix.project }}-${{ steps.artifact.outputs.spec_name }} + path: client/e2eTests/protoFleet/test-results/ + if-no-files-found: ignore + retention-days: 7 + + - name: Dump backend logs on failure + if: failure() + working-directory: server + run: | + echo "=== Fleet API Logs ===" + docker compose logs fleet-api + echo "=== TimescaleDB Logs ===" + docker compose logs timescaledb + + - name: Cleanup + if: always() + working-directory: server + run: docker compose down -v + + merge-reports: + name: Merge Test Reports + runs-on: ubuntu-latest + needs: [e2e-tests] + if: ${{ always() && !cancelled() }} + permissions: + contents: read + outputs: + desktop-report-path: ${{ steps.merge.outputs.desktop-report-path }} + mobile-report-path: ${{ steps.merge.outputs.mobile-report-path }} + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + with: + preinstall: "false" + + - name: Cache Node modules + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + client/node_modules + key: ${{ runner.os }}-node-${{ hashFiles('client/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install client dependencies + working-directory: client + run: npm ci + + - name: Download all blob reports + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: blob-report-* + path: /tmp/blob-reports + merge-multiple: false + + - name: Merge blob reports into HTML reports + id: merge + working-directory: client/e2eTests/protoFleet + run: | + echo "Merging blob reports..." + mkdir -p merged-reports + + # Organize blob reports by project + mkdir -p /tmp/desktop-blobs /tmp/mobile-blobs + + # Copy all desktop blob reports + find /tmp/blob-reports -name "blob-report-desktop-*" -type d | while read dir; do + if [ "$(find "$dir" -name "*.zip" | wc -l)" -gt 0 ]; then + cp -r "$dir"/* /tmp/desktop-blobs/ 2>/dev/null || true + fi + done + + # Copy all mobile blob reports + find /tmp/blob-reports -name "blob-report-mobile-*" -type d | while read dir; do + if [ "$(find "$dir" -name "*.zip" | wc -l)" -gt 0 ]; then + cp -r "$dir"/* /tmp/mobile-blobs/ 2>/dev/null || true + fi + done + + # Merge desktop reports + if [ "$(find /tmp/desktop-blobs -name "*.zip" 2>/dev/null | wc -l)" -gt 0 ]; then + echo "Merging desktop reports..." + mkdir -p merged-reports/desktop/test-results + PLAYWRIGHT_HTML_OUTPUT_DIR=merged-reports/desktop/playwright-report \ + PLAYWRIGHT_JUNIT_OUTPUT_FILE=merged-reports/desktop/test-results/results.xml \ + npx playwright merge-reports --reporter=html,junit /tmp/desktop-blobs + echo "desktop-report-path=client/e2eTests/protoFleet/merged-reports/desktop" >> $GITHUB_OUTPUT + else + echo "No desktop blob reports found" + fi + + # Merge mobile reports + if [ "$(find /tmp/mobile-blobs -name "*.zip" 2>/dev/null | wc -l)" -gt 0 ]; then + echo "Merging mobile reports..." + mkdir -p merged-reports/mobile/test-results + PLAYWRIGHT_HTML_OUTPUT_DIR=merged-reports/mobile/playwright-report \ + PLAYWRIGHT_JUNIT_OUTPUT_FILE=merged-reports/mobile/test-results/results.xml \ + npx playwright merge-reports --reporter=html,junit /tmp/mobile-blobs + echo "mobile-report-path=client/e2eTests/protoFleet/merged-reports/mobile" >> $GITHUB_OUTPUT + else + echo "No mobile blob reports found" + fi + + echo "Report merging completed" + ls -la merged-reports/ || true + + - name: Upload merged Playwright reports + id: merged-report-artifact + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + if: ${{ !cancelled() }} + with: + name: playwright-report-protofleet-merged + path: client/e2eTests/protoFleet/merged-reports/ + if-no-files-found: warn + retention-days: 30 + + - name: Install Testmo CLI + if: ${{ !cancelled() && github.event_name == 'schedule' }} + run: | + npm install -g @testmo/testmo-cli@1.0.0 + + - name: Submit test results to Testmo (Desktop) + if: ${{ !cancelled() && github.event_name == 'schedule' && steps.merge.outputs.desktop-report-path }} + working-directory: client/e2eTests/protoFleet/merged-reports/desktop + env: + TESTMO_TOKEN: ${{ secrets.TESTMO_TOKEN }} + run: | + # Add XML declaration if missing (Playwright sometimes omits it on CI) + if [ -f "test-results/results.xml" ]; then + if ! head -n 1 test-results/results.xml | grep -q '' | cat - test-results/results.xml > test-results/results-fixed.xml + mv test-results/results-fixed.xml test-results/results.xml + echo "First 2 lines after fix:" + head -n 2 test-results/results.xml + else + echo "XML declaration already present" + fi + + testmo automation:run:submit \ + --instance https://proto.testmo.net \ + --project-id 2 \ + --name "ProtoFleet E2E Tests Desktop - $(date +'%Y-%m-%d %H:%M')" \ + --source "protofleet-e2e-tests-desktop" \ + --results test-results/*.xml + else + echo "No desktop test results found" + fi + + - name: Submit test results to Testmo (Mobile) + if: ${{ !cancelled() && github.event_name == 'schedule' && steps.merge.outputs.mobile-report-path }} + working-directory: client/e2eTests/protoFleet/merged-reports/mobile + env: + TESTMO_TOKEN: ${{ secrets.TESTMO_TOKEN }} + run: | + # Add XML declaration if missing (Playwright sometimes omits it on CI) + if [ -f "test-results/results.xml" ]; then + if ! head -n 1 test-results/results.xml | grep -q '' | cat - test-results/results.xml > test-results/results-fixed.xml + mv test-results/results-fixed.xml test-results/results.xml + echo "First 2 lines after fix:" + head -n 2 test-results/results.xml + else + echo "XML declaration already present" + fi + + testmo automation:run:submit \ + --instance https://proto.testmo.net \ + --project-id 2 \ + --name "ProtoFleet E2E Tests Mobile - $(date +'%Y-%m-%d %H:%M')" \ + --source "protofleet-e2e-tests-mobile" \ + --results test-results/*.xml + else + echo "No mobile test results found" + fi + + - name: Note report availability + env: + MERGED_REPORT_ARTIFACT_URL: ${{ steps.merged-report-artifact.outputs.artifact-url }} + run: | + echo "## 🎭 ProtoFleet E2E Test Reports" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + if [[ -n "${MERGED_REPORT_ARTIFACT_URL}" ]]; then + echo "📦 **[Download merged Playwright report artifact](${MERGED_REPORT_ARTIFACT_URL})**" >> "$GITHUB_STEP_SUMMARY" + else + echo "📦 **Merged test reports:** Available in the artifacts section as \`playwright-report-protofleet-merged\`" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.github/workflows/protofleet-example-python-plugin-checks.yml b/.github/workflows/protofleet-example-python-plugin-checks.yml new file mode 100644 index 000000000..a1b662060 --- /dev/null +++ b/.github/workflows/protofleet-example-python-plugin-checks.yml @@ -0,0 +1,55 @@ +name: Example Python Plugin Checks + +on: + pull_request: + paths: + - ".github/workflows/protofleet-example-python-plugin-checks.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "plugin/example-python/**" + - "server/sdk/v1/python/**" + push: + branches: + - main + paths: + - ".github/workflows/protofleet-example-python-plugin-checks.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "plugin/example-python/**" + - "server/sdk/v1/python/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + example-python-plugin: + name: example-python-plugin + runs-on: ubuntu-latest + defaults: + run: + working-directory: plugin/example-python + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Setup venv and install dependencies + run: | + python3.13 -m venv .venv + .venv/bin/pip install --upgrade pip setuptools + .venv/bin/pip install -e "../../server/sdk/v1/python" + .venv/bin/pip install -e ".[dev]" + + - name: Run tests + run: .venv/bin/pytest tests/ -v --cov=example_driver --cov-report=term-missing + + - name: Lint + run: .venv/bin/ruff check example_driver/ tests/ + + - name: Typecheck + run: .venv/bin/mypy example_driver/ diff --git a/.github/workflows/protofleet-proto-checks.yml b/.github/workflows/protofleet-proto-checks.yml new file mode 100644 index 000000000..2b4ace67d --- /dev/null +++ b/.github/workflows/protofleet-proto-checks.yml @@ -0,0 +1,41 @@ +name: Proto Checks + +on: + pull_request: + paths: + - ".github/workflows/protofleet-proto-checks.yml" + - ".github/actions/hermit-setup/action.yml" + - "buf.lock" + - "buf.yaml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "proto/**" + push: + branches: + - main + paths: + - ".github/workflows/protofleet-proto-checks.yml" + - ".github/actions/hermit-setup/action.yml" + - "buf.lock" + - "buf.yaml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "proto/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + proto-lint: + name: Protobuf Lint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Lint protobuf definitions + run: buf lint diff --git a/.github/workflows/protofleet-proto-plugin-checks.yml b/.github/workflows/protofleet-proto-plugin-checks.yml new file mode 100644 index 000000000..79355a158 --- /dev/null +++ b/.github/workflows/protofleet-proto-plugin-checks.yml @@ -0,0 +1,92 @@ +name: Proto Plugin Checks + +on: + pull_request: + paths: + - ".github/workflows/protofleet-proto-plugin-checks.yml" + - ".github/actions/go-cache-setup/action.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "go.work" + - "go.work.sum" + - "just/go-plugin.just" + - "proto/**" + - "plugin/proto/**" + - "server/fake-proto-rig/**" + - "server/go.mod" + - "server/go.sum" + - "server/sdk/**" + push: + branches: + - main + paths: + - ".github/workflows/protofleet-proto-plugin-checks.yml" + - ".github/actions/go-cache-setup/action.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "go.work" + - "go.work.sum" + - "just/go-plugin.just" + - "proto/**" + - "plugin/proto/**" + - "server/fake-proto-rig/**" + - "server/go.mod" + - "server/go.sum" + - "server/sdk/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + proto-plugin: + name: Proto Plugin + runs-on: ubuntu-latest + defaults: + run: + working-directory: plugin/proto + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Configure Go cache + uses: ./.github/actions/go-cache-setup + + - name: Check formatting + run: golangci-lint fmt --diff -c .golangci.yaml + - name: Build + run: just build + + - name: Prepare Docker for integration tests + run: | + # Proto integration tests use testcontainers and build the fake-proto-rig + # image from server/fake-proto-rig/Dockerfile. Warming the daemon and + # required base images makes CI failures deterministic. + echo "Waiting for Docker daemon to be ready..." + for i in {1..30}; do + if docker info >/dev/null 2>&1; then + echo "Docker daemon is ready" + break + fi + echo "Attempt $i: Docker daemon not ready, waiting 2 seconds..." + sleep 2 + done + + if ! docker info >/dev/null 2>&1; then + echo "ERROR: Docker daemon did not become ready after 30 attempts; aborting integration test setup." + exit 1 + fi + + docker pull hello-world:latest + docker run --rm hello-world:latest + docker pull ubuntu:22.04 + docker pull golang:1.25.4-alpine + docker pull alpine:3.21 + + - name: Check + run: just check diff --git a/.github/workflows/protofleet-server-checks.yml b/.github/workflows/protofleet-server-checks.yml new file mode 100644 index 000000000..dfd5da2df --- /dev/null +++ b/.github/workflows/protofleet-server-checks.yml @@ -0,0 +1,63 @@ +name: Server Checks + +on: + pull_request: + paths: + - ".github/workflows/protofleet-server-checks.yml" + - ".github/actions/go-cache-setup/action.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "go.work" + - "go.work.sum" + - "proto/**" + - "server/**" + push: + branches: + - main + paths: + - ".github/workflows/protofleet-server-checks.yml" + - ".github/actions/go-cache-setup/action.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "go.work" + - "go.work.sum" + - "proto/**" + - "server/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + server: + name: Server + runs-on: ubuntu-latest + defaults: + run: + working-directory: server + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Configure Go cache + uses: ./.github/actions/go-cache-setup + + - name: Check formatting + run: golangci-lint fmt --diff -c .golangci.yaml + + - name: Lint + run: just lint + + - name: Build + run: just build + + - name: Start database + run: just db-up + + - name: Test + run: just test diff --git a/.github/workflows/protofleet-virtual-plugin-checks.yml b/.github/workflows/protofleet-virtual-plugin-checks.yml new file mode 100644 index 000000000..766d2b0cd --- /dev/null +++ b/.github/workflows/protofleet-virtual-plugin-checks.yml @@ -0,0 +1,57 @@ +name: Virtual Plugin Checks + +on: + pull_request: + paths: + - ".github/workflows/protofleet-virtual-plugin-checks.yml" + - ".github/actions/go-cache-setup/action.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "go.work" + - "go.work.sum" + - "server/go.mod" + - "server/go.sum" + - "server/sdk/**" + - "plugin/virtual/**" + push: + branches: + - main + paths: + - ".github/workflows/protofleet-virtual-plugin-checks.yml" + - ".github/actions/go-cache-setup/action.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "go.work" + - "go.work.sum" + - "server/go.mod" + - "server/go.sum" + - "server/sdk/**" + - "plugin/virtual/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + virtual-plugin: + name: Virtual Plugin + runs-on: ubuntu-latest + defaults: + run: + working-directory: plugin/virtual + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Configure Go cache + uses: ./.github/actions/go-cache-setup + + - name: Check formatting + run: test -z "$(gofmt -l .)" + - name: Build + run: go build ./... diff --git a/.github/workflows/protoos-e2e-tests.yml b/.github/workflows/protoos-e2e-tests.yml new file mode 100644 index 000000000..cf8d278c5 --- /dev/null +++ b/.github/workflows/protoos-e2e-tests.yml @@ -0,0 +1,157 @@ +name: ProtoOS E2E Tests + +on: + pull_request: + paths: + - ".github/workflows/protoos-e2e-tests.yml" + - "client/.npmrc" + - "client/e2eTests/protoOS/**" + - "client/package.json" + - "client/package-lock.json" + - "client/postcss.config.js" + - "client/public/**" + - "client/src/protoOS/**" + - "client/src/shared/**" + - "client/tsconfig.json" + - "client/tsconfig.node.json" + - "client/vite.config.ts" + - "client/vitePlugins/**" + - "server/go.mod" + - "server/go.sum" + - "server/fake-proto-rig/**" + schedule: + # Run daily at 6 AM UTC on main branch + - cron: "0 6 * * *" + workflow_dispatch: + +jobs: + e2e-tests: + timeout-minutes: 30 + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + project: [desktop, mobile] + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Build fake-proto-rig simulator + run: | + echo "Building fake-proto-rig simulator..." + docker build \ + -t fake-proto-rig \ + -f server/fake-proto-rig/Dockerfile \ + . + + - name: Start fake-proto-rig simulator + run: | + docker run -d \ + --name fake-proto-rig \ + -p 8080:8080 \ + fake-proto-rig + + - name: Cache Node modules + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + client/node_modules + ~/.cache/ms-playwright + key: ${{ runner.os }}-node-${{ hashFiles('client/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install client dependencies + working-directory: client + run: npm ci + + - name: Build ProtoOS frontend + working-directory: client + run: npm run build:protoOS + + - name: Start ProtoOS frontend + working-directory: client + run: | + npm run preview:protoOS -- --port 3000 --host 0.0.0.0 --strictPort > /tmp/vite-preview.log 2>&1 & + env: + PROXY_URL: http://localhost:8080 + + - name: Install Playwright browsers + working-directory: client/e2eTests/protoOS + run: npx playwright install --with-deps chromium + + - name: Run Playwright tests (${{ matrix.project }}) + working-directory: client/e2eTests/protoOS + run: npx playwright test --project=${{ matrix.project }} + env: + CI: true + + - name: Upload Playwright report + id: playwright-report-artifact + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + if: ${{ !cancelled() }} + with: + name: playwright-report-protoos-${{ matrix.project }} + path: client/e2eTests/protoOS/playwright-report/ + retention-days: 30 + + - name: Note report availability + if: ${{ !cancelled() }} + env: + PLAYWRIGHT_REPORT_ARTIFACT_URL: ${{ steps.playwright-report-artifact.outputs.artifact-url }} + run: | + echo "## ProtoOS E2E Report (${{ matrix.project }})" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + if [[ -n "${PLAYWRIGHT_REPORT_ARTIFACT_URL}" ]]; then + echo "📦 **[Download Playwright report artifact](${PLAYWRIGHT_REPORT_ARTIFACT_URL})**" >> "$GITHUB_STEP_SUMMARY" + else + echo "📦 **Playwright report:** Available in the artifacts section as \`playwright-report-protoos-${{ matrix.project }}\`" >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Install Testmo CLI + if: ${{ !cancelled() && github.event_name == 'schedule' }} + run: | + npm install -g @testmo/testmo-cli@1.0.0 + + - name: Submit test results to Testmo + if: ${{ !cancelled() && github.event_name == 'schedule' }} + working-directory: client/e2eTests/protoOS + env: + TESTMO_TOKEN: ${{ secrets.TESTMO_TOKEN }} + run: | + # Add XML declaration if missing (Playwright sometimes omits it on CI) + if ! head -n 1 test-results/results.xml | grep -q '' | cat - test-results/results.xml > test-results/results-fixed.xml + mv test-results/results-fixed.xml test-results/results.xml + echo "First 2 lines after fix:" + head -n 2 test-results/results.xml + else + echo "XML declaration already present" + fi + + testmo automation:run:submit \ + --instance https://proto.testmo.net \ + --project-id 2 \ + --name "Proto OS E2E Tests - ${{ matrix.project }} - $(date +'%Y-%m-%d %H:%M')" \ + --source "protoos-e2e-tests-${{ matrix.project }}" \ + --results test-results/*.xml + + - name: Upload test screenshots + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + if: failure() + with: + name: playwright-screenshots-protoos-${{ matrix.project }} + path: client/e2eTests/protoOS/test-results/ + retention-days: 7 + + - name: Dump simulator logs on failure + if: failure() + run: | + echo "=== fake-proto-rig Logs ===" + docker logs fake-proto-rig 2>&1 || echo "No fake-proto-rig logs available" diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 000000000..6cb0021fe --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,32 @@ +name: Pull Request + +on: pull_request_target + +permissions: + contents: read + repository-projects: read + pull-requests: write + +jobs: + triage: + name: Triage + runs-on: ubuntu-latest + + steps: + - name: Apply labels + uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" + + auto-assign: + name: Auto-Assign + runs-on: ubuntu-latest + if: github.actor != 'dependabot[bot]' + + steps: + - name: Assign PR to Creator + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + gh pr edit ${{ github.event.pull_request.number }} --add-assignee ${{ github.actor }} diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 000000000..9db3af5b4 --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,140 @@ +name: Python SDK and Generator Checks + +on: + pull_request: + paths: + - ".github/workflows/python-tests.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "packages/proto-python-gen/**" + - "server/buf.gen.yaml" + - "server/buf.yaml" + - "server/sdk/v1/pb/**" + - "server/sdk/v1/python/**" + push: + branches: + - main + paths: + - ".github/workflows/python-tests.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "packages/proto-python-gen/**" + - "server/buf.gen.yaml" + - "server/buf.yaml" + - "server/sdk/v1/pb/**" + - "server/sdk/v1/python/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + proto-python-gen: + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/proto-python-gen + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Setup venv + run: just setup + + - name: Run tests + run: just test + + - name: Lint + run: just lint + + sdk-python: + runs-on: ubuntu-latest + defaults: + run: + working-directory: server/sdk/v1/python + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Setup venv + run: just setup + + - name: Run checks (tests + typecheck + lint) + run: just check + + python-gen-staleness: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Setup protoc-gen-python-grpc venv + run: just setup + working-directory: packages/proto-python-gen + + - name: Regenerate Python SDK stubs + run: just gen-sdk-protos + working-directory: server + + - name: Check for staleness + run: | + if ! git diff --exit-code server/sdk/v1/python/proto_fleet_sdk/generated/; then + echo "ERROR: Generated Python SDK stubs are out of date." + echo "Run 'cd server && just gen-sdk-protos' and commit the changes." + exit 1 + fi + echo "Generated stubs are up to date." + proto-python-gen-tarball: + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/proto-python-gen + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Validate tarball contents match source + run: | + TARBALL="proto-python-gen-0.2.0.tar.gz" + if [[ ! -f "${TARBALL}" ]]; then + echo "ERROR: Committed tarball ${TARBALL} not found." + echo "Run 'cd packages/proto-python-gen && just package' and commit the tarball." + exit 1 + fi + + COMMITTED="$(mktemp -d)" + EXPECTED="$(mktemp -d)" + trap 'rm -rf "${COMMITTED}" "${EXPECTED}"' EXIT + + tar -xzf "${TARBALL}" -C "${COMMITTED}" + + mkdir -p "${EXPECTED}/bin" + cp bin/protoc-gen-python-grpc "${EXPECTED}/bin/" + cp protoc_gen_python_grpc.py "${EXPECTED}/" + cp setup.sh "${EXPECTED}/" + cp ../../scripts/pip-config.sh "${EXPECTED}/" + cp requirements.txt "${EXPECTED}/" + + if diff -r "${COMMITTED}" "${EXPECTED}" > /dev/null 2>&1; then + echo "Tarball contents match source files." + else + echo "ERROR: Committed tarball does not match source files." + diff -r "${COMMITTED}" "${EXPECTED}" || true + echo "" + echo "Run 'cd packages/proto-python-gen && just package' and commit the updated tarball." + exit 1 + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..d876f1363 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,538 @@ +name: Create ProtoFleet Release Artifacts + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + pull-requests: read + +jobs: + validate-tag: + runs-on: ubuntu-latest + steps: + - name: Validate tag format + env: + TAG_NAME: ${{ github.ref_name }} + run: | + if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9._-]+)?$ ]]; then + echo "::error::Tag '$TAG_NAME' does not match expected format (e.g., v1.2.3 or v1.2.3-rc.1)" + exit 1 + fi + echo "Tag '$TAG_NAME' is valid" + + - name: Require full releases to be on main + if: ${{ !contains(github.ref_name, '-') }} + env: + GH_TOKEN: ${{ github.token }} + run: | + AHEAD_BY=$(gh api "repos/${{ github.repository }}/compare/main...${{ github.sha }}" --jq '.ahead_by') + if [[ "$AHEAD_BY" -ne 0 ]]; then + echo "::error::Full releases are only allowed from commits on the main branch. Tag '${{ github.ref_name }}' points to a commit not on main." + exit 1 + fi + echo "Full release tag is on main — OK" + + build-proto-fleet-windows-installer: + runs-on: windows-latest + needs: [validate-tag] + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup .NET SDK from global.json + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + with: + global-json-file: deployment-files/windows/global.json + + - name: Show .NET SDK info + shell: powershell + run: dotnet --info + + - name: Install ps2exe + shell: powershell + run: | + Set-PSRepository -Name "PSGallery" -InstallationPolicy Trusted + Install-Module -Name ps2exe -Force -Scope CurrentUser + + - name: Build Windows installer EXE + shell: powershell + working-directory: ./deployment-files/windows + run: | + $outputDir = Join-Path $PWD "artifacts/release-installer" + ./build-fleet-installer.ps1 -Configuration Release -OutputDir $outputDir + $installerPath = Join-Path $outputDir "installer.exe" + if (-not (Test-Path $installerPath)) { + throw "Installer build completed but installer.exe was not found in $outputDir." + } + Write-Host "Windows installer prepared at $installerPath" + + - name: Build Windows uninstaller EXE + shell: powershell + working-directory: ./deployment-files/windows + run: | + $outputDir = Join-Path $PWD "artifacts/release-installer" + $uninstallerPath = Join-Path $outputDir "uninstall.exe" + ./build-fleet-uninstaller-exe.ps1 -OutputExe $uninstallerPath + if (-not (Test-Path $uninstallerPath)) { + throw "Uninstaller build completed but uninstall.exe was not found at $uninstallerPath." + } + Write-Host "Windows uninstaller prepared at $uninstallerPath" + + - name: Upload Windows installer artifacts + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: proto-fleet-windows-installer + path: | + ./deployment-files/windows/artifacts/release-installer/installer.exe + ./deployment-files/windows/artifacts/release-installer/uninstall.exe + retention-days: 1 + + build-proto-fleet-server: + needs: [validate-tag] + env: + TAG_NAME: ${{ github.ref_name }} + strategy: + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Configure Go cache + uses: ./.github/actions/go-cache-setup + + - name: Build Golang server (${{ matrix.arch }}) + working-directory: ./server + run: | + go mod download + go build -v -o fleetd-${{ matrix.arch }} ./cmd/fleetd + echo "version: $TAG_NAME" > version.txt + if [[ "$TAG_NAME" == *-* ]]; then IS_PRERELEASE=true; else IS_PRERELEASE=false; fi + echo "is_prerelease: $IS_PRERELEASE" >> version.txt + echo "build_date: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> version.txt + echo "commit: ${{ github.sha }}" >> version.txt + + - name: Build Go plugin binaries (${{ matrix.arch }}) + run: | + echo "Syncing Go workspace..." + go work sync + echo "Building Go plugins for ${{ matrix.arch }}..." + go build -o server/proto-plugin-${{ matrix.arch }} ./plugin/proto + go build -o server/antminer-plugin-${{ matrix.arch }} ./plugin/antminer + chmod +x server/proto-plugin-* server/antminer-plugin-* + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Build asicrs plugin binary (${{ matrix.arch }}) + run: | + docker buildx build \ + --file plugin/asicrs/Dockerfile.build \ + --output "type=local,dest=/tmp/asicrs" \ + . + cp "/tmp/asicrs/asicrs-plugin" "server/asicrs-plugin-${{ matrix.arch }}" + cp "/tmp/asicrs/asicrs-config.yaml" "server/asicrs-config.yaml" + chmod +x server/asicrs-plugin-* + echo "asicrs plugin built successfully for ${{ matrix.arch }}" + + - name: Package server binaries (${{ matrix.arch }}) + working-directory: ./server + run: | + tar -czf "proto-fleet-server-${TAG_NAME}-${{ matrix.arch }}.tar.gz" \ + fleetd-${{ matrix.arch }} \ + proto-plugin-${{ matrix.arch }} \ + antminer-plugin-${{ matrix.arch }} \ + asicrs-plugin-${{ matrix.arch }} \ + asicrs-config.yaml \ + version.txt + + - name: Upload server artifact for job use + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: proto-fleet-server-artifact-${{ matrix.arch }} + path: ./server/proto-fleet-server-${{ github.ref_name }}-${{ matrix.arch }}.tar.gz + retention-days: 1 + + build-proto-fleet-client: + runs-on: ubuntu-latest + needs: [validate-tag] + env: + TAG_NAME: ${{ github.ref_name }} + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + cache: "npm" + cache-dependency-path: "./client/package-lock.json" + + - name: Install dependencies + working-directory: ./client + run: npm ci + + - name: Build ProtoFleet client + working-directory: ./client + run: | + BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + export VITE_VERSION="$TAG_NAME" + export VITE_BUILD_DATE="$BUILD_DATE" + export VITE_COMMIT="${{ github.sha }}" + npm run build:protoFleet + + - name: Create version file + working-directory: ./client + run: | + echo "version: $TAG_NAME" > dist/protoFleet/version.txt + echo "build_date: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> dist/protoFleet/version.txt + echo "commit: ${{ github.sha }}" >> dist/protoFleet/version.txt + + - name: Package ProtoFleet client + working-directory: ./client + run: | + tar -czf "proto-fleet-client-${TAG_NAME}.tar.gz" dist/protoFleet + + - name: Upload client artifact for job use + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: proto-fleet-client-artifact + path: ./client/proto-fleet-client-${{ github.ref_name }}.tar.gz + retention-days: 1 + + build-proto-os: + runs-on: ubuntu-latest + needs: [validate-tag] + env: + TAG_NAME: ${{ github.ref_name }} + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + cache: "npm" + cache-dependency-path: "./client/package-lock.json" + + - name: Install dependencies + working-directory: ./client + run: npm ci + + - name: Build ProtoOS + working-directory: ./client + run: | + BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + export VITE_VERSION="$TAG_NAME" + export VITE_BUILD_DATE="$BUILD_DATE" + export VITE_COMMIT="${{ github.sha }}" + npm run build:protoOS + + - name: Create version file + working-directory: ./client + run: | + echo "version: $TAG_NAME" > dist/protoOS/version.txt + echo "build_date: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> dist/protoOS/version.txt + echo "commit: ${{ github.sha }}" >> dist/protoOS/version.txt + + - name: Package ProtoOS + working-directory: ./client + run: | + tar -czf "proto-os-${TAG_NAME}.tar.gz" dist/protoOS + + - name: Create version file for control board package .ipk + working-directory: ./client + run: echo "$TAG_NAME" > dist/web_dashboard_version + + - name: Build ProtoOS .ipk package + working-directory: ./client + run: | + nfpm package --config nfpm-proto-os.yaml --packager ipk --target "proto-os_${VERSION}.ipk" + env: + VERSION: ${{ github.ref_name }} + + - name: Upload ProtoOS artifacts + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: proto-os-artifact + path: | + ./client/proto-os-${{ github.ref_name }}.tar.gz + ./client/proto-os_${{ github.ref_name }}.ipk + retention-days: 1 + + build-timescaledb-image: + needs: [validate-tag] + strategy: + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Build TimescaleDB image (${{ matrix.arch }}) + run: | + docker buildx build \ + -t proto-fleet-timescaledb:latest \ + --output type=docker,dest=timescaledb-${{ matrix.arch }}.tar \ + server/timescaledb + gzip -9 timescaledb-${{ matrix.arch }}.tar + echo "${{ matrix.arch }} image size: $(du -h timescaledb-${{ matrix.arch }}.tar.gz | cut -f1)" + + - name: Upload TimescaleDB image artifact + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: proto-fleet-timescaledb-${{ matrix.arch }} + path: timescaledb-${{ matrix.arch }}.tar.gz + retention-days: 1 + + build-proto-fleet: + runs-on: ubuntu-latest + needs: [build-proto-fleet-server, build-proto-fleet-client, build-proto-fleet-windows-installer, build-timescaledb-image] + env: + TAG_NAME: ${{ github.ref_name }} + strategy: + matrix: + arch: [amd64, arm64] + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Create deployment directory structure + run: | + mkdir -p deployment/server + mkdir -p deployment/client + + - name: Download server artifact (${{ matrix.arch }}) + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: proto-fleet-server-artifact-${{ matrix.arch }} + path: /tmp/server-artifacts + + - name: Download client artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: proto-fleet-client-artifact + path: /tmp + + - name: Download Windows installer artifact + if: matrix.arch == 'amd64' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: proto-fleet-windows-installer + path: /tmp/windows-installer + + - name: Download TimescaleDB image artifact (${{ matrix.arch }}) + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: proto-fleet-timescaledb-${{ matrix.arch }} + path: /tmp/timescaledb-images + + - name: Extract artifacts + run: | + # Extract server artifacts and rename to generic names (no arch suffix) + tar -xzf "/tmp/server-artifacts/proto-fleet-server-${TAG_NAME}-${{ matrix.arch }}.tar.gz" -C deployment/server + cd deployment/server + mv fleetd-${{ matrix.arch }} fleetd + mv proto-plugin-${{ matrix.arch }} proto-plugin + mv antminer-plugin-${{ matrix.arch }} antminer-plugin + mv asicrs-plugin-${{ matrix.arch }} asicrs-plugin + cd ../.. + + # Extract client + mkdir -p /tmp/client + tar -xzf "/tmp/proto-fleet-client-${TAG_NAME}.tar.gz" -C /tmp/client + mkdir -p deployment/client/protoFleet + cp -r /tmp/client/dist/protoFleet/* deployment/client/protoFleet/ + + # Bundle Windows installer in amd64 tarball only + if [ "${{ matrix.arch }}" = "amd64" ]; then + mkdir -p deployment/install + cp /tmp/windows-installer/installer.exe deployment/install/installer.exe + cp /tmp/windows-installer/uninstall.exe deployment/install/uninstall.exe + fi + + - name: Copy deployment configuration files + run: | + cp deployment-files/client/Dockerfile deployment/client/ + cp deployment-files/client/nginx.http.conf deployment/client/ + cp deployment-files/client/nginx.https.conf deployment/client/ + cp deployment-files/server/Dockerfile deployment/server/ + cp deployment-files/docker-compose.yaml deployment/ + cp server/docker-compose.base.yaml deployment/server/ + cp deployment-files/run-fleet.sh deployment/ + cp deployment-files/uninstall.sh deployment/ + cp -r deployment-files/scripts deployment/ + chmod +x deployment/run-fleet.sh deployment/uninstall.sh deployment/scripts/*.sh + + # Pre-built TimescaleDB Docker image for this architecture + mkdir -p deployment/images + cp /tmp/timescaledb-images/timescaledb-${{ matrix.arch }}.tar.gz deployment/images/timescaledb.tar.gz + + - name: Create version file + run: | + echo "version: $TAG_NAME" > deployment/version.txt + echo "build_date: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> deployment/version.txt + echo "commit: ${{ github.sha }}" >> deployment/version.txt + + - name: Package ProtoFleet deployment bundle (${{ matrix.arch }}) + run: | + tar -czf "proto-fleet-${TAG_NAME}-${{ matrix.arch }}.tar.gz" deployment + + - name: Upload deployment bundle artifact + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: proto-fleet-deployment-bundle-${{ matrix.arch }} + path: proto-fleet-${{ github.ref_name }}-${{ matrix.arch }}.tar.gz + retention-days: 1 + + publish-proto-fleet: + runs-on: ubuntu-latest + needs: [build-proto-fleet, build-proto-os] + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Download deployment bundle artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: proto-fleet-deployment-bundle-* + path: /tmp/release-assets/bundles + merge-multiple: true + + - name: Download Windows installer artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: proto-fleet-windows-installer + path: /tmp/release-assets/windows + + - name: Download server artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: proto-fleet-server-artifact-* + path: /tmp/release-assets/server + merge-multiple: true + + - name: Download client artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: proto-fleet-client-artifact + path: /tmp/release-assets/client + + - name: Download ProtoOS artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: proto-os-artifact + path: /tmp/release-assets/proto-os + + - name: List all release assets + run: find /tmp/release-assets -type f | sort + + - name: Create draft release and upload assets + id: create_release + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + with: + draft: true + prerelease: ${{ contains(github.ref_name, '-') }} + generate_release_notes: true + files: | + /tmp/release-assets/bundles/proto-fleet-${{ github.ref_name }}-*.tar.gz + /tmp/release-assets/windows/installer.exe + /tmp/release-assets/windows/uninstall.exe + /tmp/release-assets/server/proto-fleet-server-${{ github.ref_name }}-*.tar.gz + /tmp/release-assets/client/proto-fleet-client-${{ github.ref_name }}.tar.gz + /tmp/release-assets/proto-os/proto-os-${{ github.ref_name }}.tar.gz + /tmp/release-assets/proto-os/proto-os_${{ github.ref_name }}.ipk + ./deployment-files/install.sh + ./deployment-files/uninstall.sh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release edit "${{ github.ref_name }}" --draft=false + + deploy-to-pi-mar: + runs-on: [self-hosted, proto-fleet-rpi, 'pi-mar'] + needs: [publish-proto-fleet] + timeout-minutes: 30 + environment: pi-mar + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Deploy ProtoFleet to pi-mar + uses: ./.github/actions/deploy-protofleet + with: + artifact_name: proto-fleet-deployment-bundle-arm64 + install_dir: ${{ vars.INSTALL_DIR }} + + deploy-to-pi-stl: + runs-on: [self-hosted, proto-fleet-rpi, 'pi-stl'] + needs: [publish-proto-fleet] + timeout-minutes: 30 + environment: pi-stl + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Deploy ProtoFleet to pi-stl + uses: ./.github/actions/deploy-protofleet + with: + artifact_name: proto-fleet-deployment-bundle-arm64 + install_dir: ${{ vars.INSTALL_DIR }} + + deploy-to-pi-fxsj: + runs-on: [self-hosted, proto-fleet-rpi, 'pi-fxsj'] + needs: [publish-proto-fleet] + timeout-minutes: 30 + environment: pi-fxsj + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Deploy ProtoFleet to pi-fxsj + uses: ./.github/actions/deploy-protofleet + with: + artifact_name: proto-fleet-deployment-bundle-arm64 + install_dir: ${{ vars.INSTALL_DIR }} + + deploy-to-pi-dalton: + runs-on: [self-hosted, proto-fleet-rpi, 'pi-dalton'] + needs: [publish-proto-fleet] + timeout-minutes: 30 + environment: pi-dalton + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Deploy ProtoFleet to pi-dalton + uses: ./.github/actions/deploy-protofleet + with: + artifact_name: proto-fleet-deployment-bundle-arm64 + install_dir: ${{ vars.INSTALL_DIR }} diff --git a/.github/workflows/rust-sdk-checks.yml b/.github/workflows/rust-sdk-checks.yml new file mode 100644 index 000000000..3beeb5ca5 --- /dev/null +++ b/.github/workflows/rust-sdk-checks.yml @@ -0,0 +1,105 @@ +name: Rust SDK Checks + +on: + pull_request: + paths: + - ".github/workflows/rust-sdk-checks.yml" + - "sdk/rust/**" + - "server/sdk/v1/pb/driver.proto" + push: + branches: + - main + paths: + - ".github/workflows/rust-sdk-checks.yml" + - "sdk/rust/**" + - "server/sdk/v1/pb/driver.proto" + workflow_dispatch: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + rust-lint: + name: Rust Lint & Format + runs-on: ubuntu-latest + defaults: + run: + working-directory: sdk/rust/proto-fleet-plugin + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + components: rustfmt, clippy + + - name: Install protoc + uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Cache Cargo registry & build + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + sdk/rust/proto-fleet-plugin/target + key: ${{ runner.os }}-cargo-${{ hashFiles('sdk/rust/proto-fleet-plugin/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Check formatting + run: cargo fmt -- --check + + - name: Clippy (default features) + run: cargo clippy -- -D warnings + + - name: Clippy (http-client feature) + run: cargo clippy --features http-client -- -D warnings + + rust-build-test: + name: Rust Build & Test + runs-on: ubuntu-latest + needs: [rust-lint] + defaults: + run: + working-directory: sdk/rust/proto-fleet-plugin + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + + - name: Install protoc + uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Cache Cargo registry & build + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + sdk/rust/proto-fleet-plugin/target + key: ${{ runner.os }}-cargo-${{ hashFiles('sdk/rust/proto-fleet-plugin/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Build (default features) + run: cargo build + + - name: Build (http-client feature) + run: cargo build --features http-client + + - name: Test (default features) + run: cargo test + + - name: Test (http-client feature) + run: cargo test --features http-client diff --git a/.github/workflows/windows-csharp-checks.yml b/.github/workflows/windows-csharp-checks.yml new file mode 100644 index 000000000..a58e78cdf --- /dev/null +++ b/.github/workflows/windows-csharp-checks.yml @@ -0,0 +1,70 @@ +name: Windows C# Checks + +on: + pull_request: + paths: + - ".github/workflows/windows-csharp-checks.yml" + - "deployment-files/windows/**" + push: + branches: + - main + paths: + - ".github/workflows/windows-csharp-checks.yml" + - "deployment-files/windows/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + quality: + name: Format, Build, and Package + runs-on: windows-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup .NET SDK from global.json + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + with: + global-json-file: deployment-files/windows/global.json + + - name: Show .NET SDK info + shell: powershell + run: dotnet --info + + - name: Install ps2exe + shell: powershell + run: | + Set-PSRepository -Name "PSGallery" -InstallationPolicy Trusted + Install-Module -Name ps2exe -Force -Scope CurrentUser + + - name: Restore installer solution + shell: powershell + run: dotnet restore deployment-files/windows/ProtoFleet.Installer.sln --configfile deployment-files/windows/NuGet.Config + + - name: Check formatting + shell: powershell + run: dotnet format deployment-files/windows/ProtoFleet.Installer.sln --verify-no-changes --no-restore + + - name: Build installer solution + shell: powershell + run: dotnet build deployment-files/windows/ProtoFleet.Installer.sln -c Release --no-restore + + - name: Validate installer and uninstaller packaging + shell: powershell + working-directory: ./deployment-files/windows + run: | + $outputDir = Join-Path $env:RUNNER_TEMP "proto-fleet-installer" + ./build-fleet-installer.ps1 -Configuration Release -OutputDir $outputDir + $installerPath = Join-Path $outputDir "installer.exe" + if (-not (Test-Path $installerPath)) { + throw "No installer executable produced at $installerPath." + } + $uninstallerPath = Join-Path $outputDir "uninstall.exe" + ./build-fleet-uninstaller-exe.ps1 -OutputExe $uninstallerPath + if (-not (Test-Path $uninstallerPath)) { + throw "No uninstaller executable produced at $uninstallerPath." + } + Write-Host "Validated installer package: $installerPath" + Write-Host "Validated uninstaller package: $uninstallerPath" diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..97a562fab --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +.hermit/ +.idea +.DS_Store +node_modules/ +server/influx_config/.env +**/.goose/ +.claude/ +.mcp.json +lefthook-local.yml + +**private.env** +.env* + +# Binary files +server/cmd/fleetd/fleetd +server/fake-antminer/fake-antminer +server/fake-proto-rig/fake-proto-rig +server/miner-debug-cli +server/devtools/seedtelemetry/seedtelemetry + +# Plugin binaries (built artifacts should not be committed) +plugin/proto/proto-plugin +plugin/antminer/antminer-plugin +plugin/virtual/virtual +plugin/asicrs/target/ + +server/plugins/* + +# Generated nginx config (copied from nginx.http.conf or nginx.https.conf by run-fleet.sh) +deployment-files/client/nginx.conf + +# proto-python-gen package artifacts +packages/proto-python-gen/.venv +packages/proto-python-gen/tests/output*/ +server/sdk/v1/python/.venv +server/plugins-active/ +server/docker-compose.plugins.yaml diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..84292128c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,38 @@ +{ + // Editor + "files.exclude": { + "**/target/": true, + "**/vendor/": true, + "**/.hermit/": true, + "**/docs/site/": true, + "**/sstate-cache/": true, + "**/artifacts/": true, + "**/build/": true + }, + "git.autoRepositoryDetection": false, + "git.detectSubmodules": false, + "search.exclude": { + "**/.git": true + }, + "editor.formatOnSave": true, + "files.insertFinalNewline": true, + + // Hermit + "terminal.integrated.env.osx": { + "ACTIVE_HERMIT": null, + "HERMIT_ENV": null, + "HERMIT_ENV_OPS": null, + "HERMIT_BIN": null + }, + + // Protobuf + "protoc": { + "path": "protoc", + "compile_flags": [ + "--proto_path=proto" + ] + }, + "clangd.arguments": [ + "--proto-path=proto" + ] +} diff --git a/CODEOWNERS b/CODEOWNERS index 7efe6fdbd..59c2a20fc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -9,7 +9,7 @@ # The format is described: https://github.blog/2017-07-06-introducing-code-owners/ # These owners will be the default owners for everything in the repo. -* @mcharles-square +* @mcharles-square @negarn @ankitgoswami @rongxin-liu @flesher # ----------------------------------------------- diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..b8e821860 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,135 @@ + +# Block Code of Conduct + +Block's mission is Economic Empowerment. This means opening the global economy to everyone. We extend the same principles of inclusion to our developer ecosystem. We are excited to build with you. So we will ensure our community is truly open, transparent and inclusive. Because of the global nature of our project, diversity and inclusivity is paramount to our success. We not only welcome diverse perspectives, we **need** them! + +The code of conduct below reflects the expectations for ourselves and for our community. + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, physical appearance, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful and welcoming of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +The Block Open Source Governance Committee (GC) is responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +The GC has the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all project spaces, and it also applies when an individual is representing the project or its community in public spaces. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event, or any space where the project is listed as part of your profile. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the Block Open Source Governance Committee (GC) at +`open-source-governance@block.xyz`. All complaints will be reviewed and +investigated promptly and fairly. + +The GC is obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +The GC will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from the GC, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media and forums. + +Although this list cannot be exhaustive, we explicitly honor diversity in age, culture, ethnicity, gender identity or expression, language, national origin, political beliefs, profession, race, religion, sexual orientation, socioeconomic status, and technical ability. We will not tolerate discrimination based on any of the protected characteristics above, including participants with disabilities. + +Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..16f2d6e0a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,222 @@ +# Contributing to Proto Fleet + +Thank you for your interest in contributing to Proto Fleet! This guide covers the development workflows and conventions used in this project. + +## Reporting Issues + +We use GitHub issue templates to keep reports consistent and actionable. When you open a new issue you will see an issue chooser with these options: + +- **Bug Report** — something is broken or behaving unexpectedly +- **Feature Request** — suggest a new feature or improvement +- **Miner Compatibility Issue** — report a problem with a specific miner model or firmware version + +If you have a question or want to start a discussion, head to [GitHub Discussions](https://github.com/block/proto-fleet/discussions) instead. For security vulnerabilities, follow the process described in [SECURITY.md](SECURITY.md). + +## Development Setup + +Start with the [README](README.md) for the basic development flow, then use the details here for contributor-specific setup requirements. + +### Hermit Setup + +If you use Hermit, activate the managed toolchain and install project dependencies: + +```bash +source ./bin/activate-hermit +just setup +``` + +After your toolchain is ready, install Git hooks: + +```bash +just install-hooks +``` + +### Non-Hermit Setup + +If you are not using Hermit, install the required toolchain yourself before running project tasks. The repository recipes and hooks expect these binaries to be available in `PATH` as needed: + +- `just` for top-level and per-project task runners +- `go` for server and Go plugin workflows +- `node` and `npm` for client setup, linting, testing, and Storybook +- `buf` for protobuf linting and generation +- `lefthook` for Git hook installation +- `golangci-lint` for Go linting and pre-push checks +- `goimports` for Go formatting and code generation follow-up +- `sqlc` for generating server query bindings +- `migrate` for creating and running database migrations + +Python-specific tooling depends on the files you change: + +- `packages/proto-python-gen`: `cd packages/proto-python-gen && just setup-dev` +- `server/sdk/v1/python`: `cd server/sdk/v1/python && just setup` +- `plugin/example-python` and other Python paths: install `ruff` in `PATH`, or set `PROTO_FLEET_RUFF=/path/to/ruff` + +## Git Hooks + +Install Git hooks with: + +```bash +just install-hooks +``` + +If `lefthook` is not installed, `just install-hooks` will fail. Hermit users can run `source ./bin/activate-hermit` first to make `lefthook` available. Non-Hermit users need to install `lefthook` manually, then rerun `just install-hooks`. + +### Python Hook Prerequisites + +The pre-commit hooks run Ruff for staged Python files. Make sure the relevant Ruff environment is available before committing Python changes: + +- `packages/proto-python-gen`: `cd packages/proto-python-gen && just setup-dev` +- `server/sdk/v1/python`: `cd server/sdk/v1/python && just setup` +- `plugin/example-python`: install `ruff` in `PATH`, or set `PROTO_FLEET_RUFF=/path/to/ruff` +- Other Python paths: install `ruff` in `PATH`, or set `PROTO_FLEET_RUFF=/path/to/ruff` + +### Pre-Push Checks + +The pre-push hooks also run repository checks before a branch can be pushed: + +- `client`: TypeScript typechecking via `npm exec --no -- tsc --noEmit` +- `server`: `golangci-lint run -c .golangci.yaml` +- `plugin/proto`: `golangci-lint run -c .golangci.yaml` +- `plugin/antminer`: `golangci-lint run -c .golangci.yaml` + +## Git Workflow + +### Branch Naming + +Create feature branches with descriptive names: + +```bash +git checkout -b /short-description +``` + +### Commit Messages + +Follow [conventional commit](https://www.conventionalcommits.org/) format: + +```bash +git commit -m "feat: add telemetry streaming to fleet UI + +- Implement server-to-client streaming connection +- Add telemetry slice to fleet store +- Update MinerList to display live metrics" +``` + +Prefixes: + +- `feat:` — New feature +- `fix:` — Bug fix +- `refactor:` — Code refactoring +- `docs:` — Documentation changes +- `test:` — Test additions or updates +- `chore:` — Build/tooling changes + +### Pull Requests + +Create PRs with a clear summary and test plan: + +```bash +gh pr create --title "Brief description" --body "## Summary +- Bullet point summary of changes + +## Test Plan +- How to verify the changes work" +``` + +## Cross-Component Workflows + +### Adding a New API Endpoint + +1. Define the API in the appropriate `.proto` file in `proto/` +2. Run `just gen` to regenerate TypeScript and Go code +3. Implement the server handler in `server/internal/handlers/` +4. Register the handler in `server/cmd/fleetd/main.go` +5. Create a client hook in `client/src/{app}/api/` +6. Update the Zustand store slice to consume the data +7. Commit proto definitions and all generated code together + +### Making Database Schema Changes + +1. Create a migration: `cd server && just db-migration-new ` +2. Write both up and down migrations in `server/migrations/` +3. Run `just gen` to regenerate sqlc bindings +4. Update queries in `server/sqlc/queries/` if needed +5. **Never modify existing migrations after they have been deployed** + +### Adding Features to the Client + +1. Determine the target app: ProtoOS, ProtoFleet, or shared +2. Check `client/src/shared/components/` for existing reusable components +3. Place the feature in the appropriate `client/src/{app}/features/` directory +4. Create Storybook stories for new components +5. Write tests with Vitest and Testing Library + +### Adding Business Logic to the Server + +1. Add domain logic to the appropriate package in `internal/domain/` +2. Create a gRPC handler in `internal/handlers/` +3. Add tests for domain logic and handlers +4. Update stores in `internal/domain/stores/sqlstores/` if database access is needed + +## Code Generation + +All generated code must be committed to Git. Run `just gen` after: + +- Modifying protobuf definitions in `proto/` +- Changing database migrations in `server/migrations/` +- Adding or modifying sqlc queries in `server/sqlc/queries/` + +Never manually edit generated files in: + +- `client/src/protoOS/api/generatedApi.ts` +- `client/src/protoFleet/api/generated/` +- `server/generated/` + +## Component Boundaries + +Maintain strict separation between applications: + +- Code in `client/src/shared/` must not import from ProtoOS or ProtoFleet +- ProtoOS and ProtoFleet must not import from each other +- Server code is completely independent of client code + +This ensures applications remain decoupled and shared code stays truly reusable. + +## Go Workspace + +The repository uses a Go workspace (`go.work`) for integrated development: + +- All Go modules (server and plugins) are included in the workspace +- Changes across modules are immediately available without version bumps +- Both `go.work` and `go.work.sum` are committed to Git for reproducible builds +- Run `go work sync` after updating dependencies + +## Testing + +### Client + +```bash +cd client +npm test # Run all tests +npx vitest run # Run tests matching a pattern +npx vitest watch # Watch mode for a specific file +npm run storybook # Visual component testing +``` + +### Server + +```bash +cd server +just test # Run all tests +just lint # Lint code +go test ./internal/domain/pairing -v # Test a specific package +go test ./internal/domain/pairing -v -run TestName # Run a specific test +``` + +### E2E Tests + +```bash +cd server +go test -tags=e2e ./e2e # Run e2e tests (requires docker-compose) +``` + +See `server/e2e/README.md` for the full e2e testing guide. diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 6bccf4336..aeba40c4a 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -1 +1,59 @@ -## [Click here for Block Open Source Project governance information](https://github.com/block/.github/blob/main/GOVERNANCE.md) \ No newline at end of file +# Block Open Source Project Governance + + + +* [Contributors](#contributors) +* [Maintainers](#maintainers) +* [Governance Committee](#governance-committee) + + + +## Contributors + +Anyone may be a contributor to Block open source projects. Contribution may take the form of: + +* Asking and answering questions on the Discord or GitHub Issues +* Filing an issue +* Offering a feature or bug fix via a Pull Request +* Suggesting documentation improvements +* ...and more! + +Anyone with a GitHub account may use the project issue trackers and communications channels. We welcome newcomers, so don't hesitate to say hi! + +## Maintainers + +Maintainers have write access to GitHub repositories and act as project administrators. They approve and merge pull requests, cut releases, and guide collaboration with the community. They have: + +* Commit access to their project's repositories +* Write access to continuous integration (CI) jobs + +Both maintainers and non-maintainers may propose changes to +source code. The mechanism to propose such a change is a GitHub pull request. Maintainers review and merge (_land_) pull requests. + +If a maintainer opposes a proposed change, then the change cannot land. The exception is if the Governance Committee (GC) votes to approve the change despite the opposition. Usually, involving the GC is unnecessary. + +See: + +* [Code Owners - `CODEOWNERS`](./.github/CODEOWNERS) +* [Contribution Guide - `CONTRIBUTING.md`](./CONTRIBUTING.md) + +### Maintainer activities + +* Helping users and novice contributors +* Contributing code and documentation changes that improve the project +* Reviewing and commenting on issues and pull requests +* Participation in working groups +* Merging pull requests + +## Governance Committee + +The Block Open Source Governance Committee (GC) has final authority over this project, including: + +* Technical direction +* Project governance and process (including this policy) +* Contribution policy +* GitHub repository hosting +* Conduct guidelines +* Maintaining the list of maintainers + +The GC may be reached through `open-source-governance@block.xyz` and is an available resource in mediation or for sensitive cases beyond the scope of project maintainers. It operates as a "Self-appointing council or board" as defined by Red Hat: [Open Source Governance Models](https://www.redhat.com/en/blog/understanding-open-source-governance-models). diff --git a/LICENSE b/LICENSE index 862ee3c28..101bf10d0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,4 @@ + Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ @@ -48,7 +49,7 @@ "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 + submitted to the 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 @@ -60,7 +61,7 @@ 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 + on behalf of whom a Contribution has been received by the Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of @@ -106,7 +107,7 @@ (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 + within such NOTICE file, excluding any 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 @@ -175,16 +176,7 @@ 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 2024 Block, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -192,8 +184,6 @@ http://www.apache.org/licenses/LICENSE-2.0 -Copyright 2026 Block, Inc. - 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. diff --git a/Procfile b/Procfile new file mode 100644 index 000000000..9ef84c520 --- /dev/null +++ b/Procfile @@ -0,0 +1,2 @@ +server: cd server && just dev +protoFleet: cd client && npm run dev:protoFleet \ No newline at end of file diff --git a/README.md b/README.md index 0905dcff1..4f5e094a4 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,129 @@ -# proto-fleet README +

+ + Proto logo + +

+

+ Proto Fleet +

+

+ Mining management software. Evolved. +

+

+ No fees. No training. Full control.
+ Open source fleet management for bitcoin miners. +

+

+ + Proto Fleet is released under the Apache 2.0 license. + + + Client checks status. + + + Server checks status. + + + E2E tests status. + +

-Congrats, project leads! You got a new project to grow! +**Proto Fleet** is open-source fleet management software for bitcoin miners. It helps operators pair devices, monitor telemetry, and manage mining infrastructure without giving up control. Built with React and TypeScript clients, Go services, Connect RPC, Protocol Buffers, and TimescaleDB. For architecture details, see [docs/architecture.md](docs/architecture.md). -This stub is meant to help you form a strong community around your work. It's yours to adapt, and may -diverge from this initial structure. Just keep the files seeded in this repo, and the rest is yours to evolve! +## Getting Started -## Introduction +### Prerequisites -Orient users to the project here. This is a good place to start with an assumption -that the user knows very little - so start with the Big Picture and show how this -project fits into it. +- Docker and Docker Compose +- [Hermit](https://cashapp.github.io/hermit/), or a local installation of the required development tools -Then maybe a dive into what this project does. +### Initial Setup -Diagrams and other visuals are helpful here. Perhaps code snippets showing usage. +```bash +source ./bin/activate-hermit +just setup +``` -Project leads should complete, alongside this `README`: +To install Git hooks after your toolchain is ready: -* [CODEOWNERS](./CODEOWNERS) - set project lead(s) -* [CONTRIBUTING.md](./CONTRIBUTING.md) - Fill out how to: install prereqs, build, test, run, access CI, chat, discuss, file issues -* [Bug-report.md](.github/ISSUE_TEMPLATE/bug-report.md) - Fill out `Assignees` add codeowners @names -* [config.yml](.github/ISSUE_TEMPLATE/config.yml) - remove "(/add your discord channel..)" and replace the url with your Discord channel if applicable +```bash +just install-hooks +``` -The other files in this template repo may be used as-is: +For non-Hermit setup details, `lefthook` and Ruff hook prerequisites, and `go.work` guidance, see [CONTRIBUTING.md](CONTRIBUTING.md). -* [GOVERNANCE.md](./GOVERNANCE.md) -* [LICENSE](./LICENSE) +### Start Development -## Project Resources +```bash +just dev +``` -| Resource | Description | -| ------------------------------------------ | ------------------------------------------------------------------------------ | -| [CODEOWNERS](./CODEOWNERS) | Outlines the project lead(s) | -| [GOVERNANCE.md](./GOVERNANCE.md) | Project governance | -| [LICENSE](./LICENSE) | Apache License, Version 2.0 | +This starts the Go backend with Docker Compose and the Vite dev server for ProtoFleet at http://localhost:5173. + +### Protocol Buffer Code Generation + +After modifying definitions in `proto/`, regenerate generated clients and server code: + +```bash +just gen +``` + +## Supported Hardware + +Per-device feature support. + +- **✅** — supported and tested. +- **❌** — not supported. +- **🟡** — supported by [asic-rs](https://github.com/asic-rs/asic-rs), but not yet tested on this combination. + + + + + + + + + + + + + + + + +
ManufacturerProtoMicroBTBitmainCanaanBitaxeNerdAxeePICAuradine
Model lineRigWhatsMinerAntminerAvalonMinerBitAxeNerdAxeePICAuradine
FirmwareProtoOSStockStockVNishBraiins OSLuxOSMarathonStockAxeOSStockStockStock
Telemetry🟡🟡🟡🟡🟡
Reboot🟡🟡🟡🟡🟡🟡🟡🟡
Pause/Resume🟡🟡🟡🟡🟡🟡🟡🟡
Edit Pools🟡🟡🟡🟡🟡🟡🟡🟡
FW Update
Power Mode🟡🟡🟡🟡🟡🟡🟡🟡
Cooling Mode
Update Password
Download Logs
Blink LED🟡🟡🟡🟡🟡🟡🟡🟡
+ + +## Production Install + +### Latest Version + +```bash +bash <(curl -fsSL https://fleet.proto.xyz/install.sh) +``` + +### Specific Version + +```bash +bash <(curl -fsSL https://fleet.proto.xyz/install.sh) v0.1.0 +``` + +### Uninstall + +```bash +bash <(curl -fsSL https://fleet.proto.xyz/uninstall.sh) +``` + +If Proto Fleet was installed in a non-default location, pass it explicitly: + +```bash +bash <(curl -fsSL https://fleet.proto.xyz/uninstall.sh) --deployment-path /path/to/install/root +``` + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for development workflows and contribution guidelines. Project standards and community expectations are documented in [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md), [GOVERNANCE.md](GOVERNANCE.md), and [SECURITY.md](SECURITY.md). + +## License + +This project is licensed under the Apache 2.0 License. See the [LICENSE](LICENSE) file for details. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..c2c5722ae --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,28 @@ +# Security Policy + +Block recognizes the important contributions our open source community makes. We welcome +contributions, including any bug fixes or vulnerabilities that you find. We encourage you to privately +report it in the repository's `Security` tab -> `Report a vulnerability`. + +Please see [Privately reporting a security vulnerability](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) for more information. + +## Scope + +This policy applies to the [Proto Fleet](https://github.com/proto-at-block/proto-fleet) repository. + +## Disclosure Procedures + +We do not publicly disclose vulnerabilities by default. We take the security of our services very seriously and +monitor their use for indications of a malicious attack. In order to distinguish legitimate security research +from malicious attacks against our services, we promise not to bring legal action against researchers who: + +* Share with us the full details of any problem found. +* Do not disclose the issue to others until we've had a reasonable time to address it and disclosure has been approved by us. +* Do not intentionally harm the experience or usefulness of the service to others. +* Never attempt to view, modify, access, disclose, exfiltrate, use or damage data belonging to Block, its customers, or others. +* Do not attempt a denial-of-service attack. +* Do not perform any research or testing in violation of the law. + +## Security Contacts + +For assistance or escalation, please contact the Block Open Source Governance Committee: `open-source-governance@block.xyz` diff --git a/bin/.buf-1.57.2.pkg b/bin/.buf-1.57.2.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.buf-1.57.2.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.gh-2.53.0.pkg b/bin/.gh-2.53.0.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.gh-2.53.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.git-absorb-0.7.0.pkg b/bin/.git-absorb-0.7.0.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.git-absorb-0.7.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.go-1.25.4.pkg b/bin/.go-1.25.4.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.go-1.25.4.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.goimports-0.3.0.pkg b/bin/.goimports-0.3.0.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.goimports-0.3.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.golangci-lint-2.6.2.pkg b/bin/.golangci-lint-2.6.2.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.golangci-lint-2.6.2.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.grpcurl-1.9.2.pkg b/bin/.grpcurl-1.9.2.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.grpcurl-1.9.2.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.jq-1.7.1.pkg b/bin/.jq-1.7.1.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.jq-1.7.1.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.just-1.40.0.pkg b/bin/.just-1.40.0.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.just-1.40.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.lefthook-2.1.4.pkg b/bin/.lefthook-2.1.4.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.lefthook-2.1.4.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.migrate-4.18.2.pkg b/bin/.migrate-4.18.2.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.migrate-4.18.2.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.mockgen-1.6.0.pkg b/bin/.mockgen-1.6.0.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.mockgen-1.6.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.mysql-client-8.0.36.pkg b/bin/.mysql-client-8.0.36.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.mysql-client-8.0.36.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.nfpm-2.45.0.pkg b/bin/.nfpm-2.45.0.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.nfpm-2.45.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.node-22.14.0.pkg b/bin/.node-22.14.0.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.node-22.14.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.proto-python-gen-0.2.0.pkg b/bin/.proto-python-gen-0.2.0.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.proto-python-gen-0.2.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.protoc-30.2.pkg b/bin/.protoc-30.2.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.protoc-30.2.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.protoc-gen-connect-go-1.12.0.pkg b/bin/.protoc-gen-connect-go-1.12.0.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.protoc-gen-connect-go-1.12.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.protoc-gen-go-1.36.5.pkg b/bin/.protoc-gen-go-1.36.5.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.protoc-gen-go-1.36.5.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.protoc-gen-go-grpc-1.3.0.pkg b/bin/.protoc-gen-go-grpc-1.3.0.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.protoc-gen-go-grpc-1.3.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.python3-3.13.2.pkg b/bin/.python3-3.13.2.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.python3-3.13.2.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.sqlc-1.28.0.pkg b/bin/.sqlc-1.28.0.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.sqlc-1.28.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/README.hermit.md b/bin/README.hermit.md new file mode 100644 index 000000000..e889550ba --- /dev/null +++ b/bin/README.hermit.md @@ -0,0 +1,7 @@ +# Hermit environment + +This is a [Hermit](https://github.com/cashapp/hermit) bin directory. + +The symlinks in this directory are managed by Hermit and will automatically +download and install Hermit itself as well as packages. These packages are +local to this environment. diff --git a/bin/activate-hermit b/bin/activate-hermit new file mode 100755 index 000000000..fe28214d3 --- /dev/null +++ b/bin/activate-hermit @@ -0,0 +1,21 @@ +#!/bin/bash +# This file must be used with "source bin/activate-hermit" from bash or zsh. +# You cannot run it directly +# +# THIS FILE IS GENERATED; DO NOT MODIFY + +if [ "${BASH_SOURCE-}" = "$0" ]; then + echo "You must source this script: \$ source $0" >&2 + exit 33 +fi + +BIN_DIR="$(dirname "${BASH_SOURCE[0]:-${(%):-%x}}")" +if "${BIN_DIR}/hermit" noop > /dev/null; then + eval "$("${BIN_DIR}/hermit" activate "${BIN_DIR}/..")" + + if [ -n "${BASH-}" ] || [ -n "${ZSH_VERSION-}" ]; then + hash -r 2>/dev/null + fi + + echo "Hermit environment $("${HERMIT_ENV}"/bin/hermit env HERMIT_ENV) activated" +fi diff --git a/bin/activate-hermit.fish b/bin/activate-hermit.fish new file mode 100755 index 000000000..0367d2331 --- /dev/null +++ b/bin/activate-hermit.fish @@ -0,0 +1,24 @@ +#!/usr/bin/env fish + +# This file must be sourced with "source bin/activate-hermit.fish" from Fish shell. +# You cannot run it directly. +# +# THIS FILE IS GENERATED; DO NOT MODIFY + +if status is-interactive + set BIN_DIR (dirname (status --current-filename)) + + if "$BIN_DIR/hermit" noop > /dev/null + # Source the activation script generated by Hermit + "$BIN_DIR/hermit" activate "$BIN_DIR/.." | source + + # Clear the command cache if applicable + functions -c > /dev/null 2>&1 + + # Display activation message + echo "Hermit environment $($HERMIT_ENV/bin/hermit env HERMIT_ENV) activated" + end +else + echo "You must source this script: source $argv[0]" >&2 + exit 33 +end diff --git a/bin/buf b/bin/buf new file mode 120000 index 000000000..90c928b77 --- /dev/null +++ b/bin/buf @@ -0,0 +1 @@ +.buf-1.57.2.pkg \ No newline at end of file diff --git a/bin/corepack b/bin/corepack new file mode 120000 index 000000000..8084f9c4c --- /dev/null +++ b/bin/corepack @@ -0,0 +1 @@ +.node-22.14.0.pkg \ No newline at end of file diff --git a/bin/gh b/bin/gh new file mode 120000 index 000000000..80f8aafd6 --- /dev/null +++ b/bin/gh @@ -0,0 +1 @@ +.gh-2.53.0.pkg \ No newline at end of file diff --git a/bin/git-absorb b/bin/git-absorb new file mode 120000 index 000000000..f1aa2ac35 --- /dev/null +++ b/bin/git-absorb @@ -0,0 +1 @@ +.git-absorb-0.7.0.pkg \ No newline at end of file diff --git a/bin/go b/bin/go new file mode 120000 index 000000000..e6546f4a2 --- /dev/null +++ b/bin/go @@ -0,0 +1 @@ +.go-1.25.4.pkg \ No newline at end of file diff --git a/bin/gofmt b/bin/gofmt new file mode 120000 index 000000000..e6546f4a2 --- /dev/null +++ b/bin/gofmt @@ -0,0 +1 @@ +.go-1.25.4.pkg \ No newline at end of file diff --git a/bin/goimports b/bin/goimports new file mode 120000 index 000000000..4aa0e9785 --- /dev/null +++ b/bin/goimports @@ -0,0 +1 @@ +.goimports-0.3.0.pkg \ No newline at end of file diff --git a/bin/golangci-lint b/bin/golangci-lint new file mode 120000 index 000000000..16bfafb67 --- /dev/null +++ b/bin/golangci-lint @@ -0,0 +1 @@ +.golangci-lint-2.6.2.pkg \ No newline at end of file diff --git a/bin/grpcurl b/bin/grpcurl new file mode 120000 index 000000000..38dcbba5b --- /dev/null +++ b/bin/grpcurl @@ -0,0 +1 @@ +.grpcurl-1.9.2.pkg \ No newline at end of file diff --git a/bin/hermit b/bin/hermit new file mode 100755 index 000000000..7fef76924 --- /dev/null +++ b/bin/hermit @@ -0,0 +1,43 @@ +#!/bin/bash +# +# THIS FILE IS GENERATED; DO NOT MODIFY + +set -eo pipefail + +export HERMIT_USER_HOME=~ + +if [ -z "${HERMIT_STATE_DIR}" ]; then + case "$(uname -s)" in + Darwin) + export HERMIT_STATE_DIR="${HERMIT_USER_HOME}/Library/Caches/hermit" + ;; + Linux) + export HERMIT_STATE_DIR="${XDG_CACHE_HOME:-${HERMIT_USER_HOME}/.cache}/hermit" + ;; + esac +fi + +export HERMIT_DIST_URL="${HERMIT_DIST_URL:-https://github.com/cashapp/hermit/releases/download/stable}" +HERMIT_CHANNEL="$(basename "${HERMIT_DIST_URL}")" +export HERMIT_CHANNEL +export HERMIT_EXE=${HERMIT_EXE:-${HERMIT_STATE_DIR}/pkg/hermit@${HERMIT_CHANNEL}/hermit} + +if [ ! -x "${HERMIT_EXE}" ]; then + echo "Bootstrapping ${HERMIT_EXE} from ${HERMIT_DIST_URL}" 1>&2 + INSTALL_SCRIPT="$(mktemp)" + # This value must match that of the install script + INSTALL_SCRIPT_SHA256="180e997dd837f839a3072a5e2f558619b6d12555cd5452d3ab19d87720704e38" + if [ "${INSTALL_SCRIPT_SHA256}" = "BYPASS" ]; then + curl -fsSL "${HERMIT_DIST_URL}/install.sh" -o "${INSTALL_SCRIPT}" + else + # Install script is versioned by its sha256sum value + curl -fsSL "${HERMIT_DIST_URL}/install-${INSTALL_SCRIPT_SHA256}.sh" -o "${INSTALL_SCRIPT}" + # Verify install script's sha256sum + openssl dgst -sha256 "${INSTALL_SCRIPT}" | \ + awk -v EXPECTED="$INSTALL_SCRIPT_SHA256" \ + '$2!=EXPECTED {print "Install script sha256 " $2 " does not match " EXPECTED; exit 1}' + fi + /bin/bash "${INSTALL_SCRIPT}" 1>&2 +fi + +exec "${HERMIT_EXE}" --level=fatal exec "$0" -- "$@" diff --git a/bin/hermit.hcl b/bin/hermit.hcl new file mode 100644 index 000000000..5c05d9f0e --- /dev/null +++ b/bin/hermit.hcl @@ -0,0 +1,8 @@ +manage-git = false +sources = [ + "https://github.com/cashapp/hermit-packages.git", + "env:///hermit-packages", +] + +github-token-auth { +} diff --git a/bin/jq b/bin/jq new file mode 120000 index 000000000..f4ed68d73 --- /dev/null +++ b/bin/jq @@ -0,0 +1 @@ +.jq-1.7.1.pkg \ No newline at end of file diff --git a/bin/just b/bin/just new file mode 120000 index 000000000..63271c139 --- /dev/null +++ b/bin/just @@ -0,0 +1 @@ +.just-1.40.0.pkg \ No newline at end of file diff --git a/bin/lefthook b/bin/lefthook new file mode 120000 index 000000000..6e6dff205 --- /dev/null +++ b/bin/lefthook @@ -0,0 +1 @@ +.lefthook-2.1.4.pkg \ No newline at end of file diff --git a/bin/migrate b/bin/migrate new file mode 120000 index 000000000..c551b2e6c --- /dev/null +++ b/bin/migrate @@ -0,0 +1 @@ +.migrate-4.18.2.pkg \ No newline at end of file diff --git a/bin/mockgen b/bin/mockgen new file mode 120000 index 000000000..6e7806a1f --- /dev/null +++ b/bin/mockgen @@ -0,0 +1 @@ +.mockgen-1.6.0.pkg \ No newline at end of file diff --git a/bin/mysql b/bin/mysql new file mode 120000 index 000000000..42bffb0fd --- /dev/null +++ b/bin/mysql @@ -0,0 +1 @@ +.mysql-client-8.0.36.pkg \ No newline at end of file diff --git a/bin/nfpm b/bin/nfpm new file mode 120000 index 000000000..e78e01a0f --- /dev/null +++ b/bin/nfpm @@ -0,0 +1 @@ +.nfpm-2.45.0.pkg \ No newline at end of file diff --git a/bin/node b/bin/node new file mode 120000 index 000000000..8084f9c4c --- /dev/null +++ b/bin/node @@ -0,0 +1 @@ +.node-22.14.0.pkg \ No newline at end of file diff --git a/bin/npm b/bin/npm new file mode 120000 index 000000000..8084f9c4c --- /dev/null +++ b/bin/npm @@ -0,0 +1 @@ +.node-22.14.0.pkg \ No newline at end of file diff --git a/bin/npx b/bin/npx new file mode 120000 index 000000000..8084f9c4c --- /dev/null +++ b/bin/npx @@ -0,0 +1 @@ +.node-22.14.0.pkg \ No newline at end of file diff --git a/bin/pip b/bin/pip new file mode 120000 index 000000000..ce2a6be54 --- /dev/null +++ b/bin/pip @@ -0,0 +1 @@ +.python3-3.13.2.pkg \ No newline at end of file diff --git a/bin/pip3 b/bin/pip3 new file mode 120000 index 000000000..ce2a6be54 --- /dev/null +++ b/bin/pip3 @@ -0,0 +1 @@ +.python3-3.13.2.pkg \ No newline at end of file diff --git a/bin/pip3.13 b/bin/pip3.13 new file mode 120000 index 000000000..ce2a6be54 --- /dev/null +++ b/bin/pip3.13 @@ -0,0 +1 @@ +.python3-3.13.2.pkg \ No newline at end of file diff --git a/bin/protoc b/bin/protoc new file mode 120000 index 000000000..07d33ca13 --- /dev/null +++ b/bin/protoc @@ -0,0 +1 @@ +.protoc-30.2.pkg \ No newline at end of file diff --git a/bin/protoc-gen-buf-breaking b/bin/protoc-gen-buf-breaking new file mode 120000 index 000000000..90c928b77 --- /dev/null +++ b/bin/protoc-gen-buf-breaking @@ -0,0 +1 @@ +.buf-1.57.2.pkg \ No newline at end of file diff --git a/bin/protoc-gen-buf-lint b/bin/protoc-gen-buf-lint new file mode 120000 index 000000000..90c928b77 --- /dev/null +++ b/bin/protoc-gen-buf-lint @@ -0,0 +1 @@ +.buf-1.57.2.pkg \ No newline at end of file diff --git a/bin/protoc-gen-connect-go b/bin/protoc-gen-connect-go new file mode 120000 index 000000000..d58574cb0 --- /dev/null +++ b/bin/protoc-gen-connect-go @@ -0,0 +1 @@ +.protoc-gen-connect-go-1.12.0.pkg \ No newline at end of file diff --git a/bin/protoc-gen-go b/bin/protoc-gen-go new file mode 120000 index 000000000..84bd23989 --- /dev/null +++ b/bin/protoc-gen-go @@ -0,0 +1 @@ +.protoc-gen-go-1.36.5.pkg \ No newline at end of file diff --git a/bin/protoc-gen-go-grpc b/bin/protoc-gen-go-grpc new file mode 120000 index 000000000..af41d7e30 --- /dev/null +++ b/bin/protoc-gen-go-grpc @@ -0,0 +1 @@ +.protoc-gen-go-grpc-1.3.0.pkg \ No newline at end of file diff --git a/bin/protoc-gen-python-grpc b/bin/protoc-gen-python-grpc new file mode 120000 index 000000000..613ae5272 --- /dev/null +++ b/bin/protoc-gen-python-grpc @@ -0,0 +1 @@ +.proto-python-gen-0.2.0.pkg \ No newline at end of file diff --git a/bin/pydoc3 b/bin/pydoc3 new file mode 120000 index 000000000..ce2a6be54 --- /dev/null +++ b/bin/pydoc3 @@ -0,0 +1 @@ +.python3-3.13.2.pkg \ No newline at end of file diff --git a/bin/pydoc3.13 b/bin/pydoc3.13 new file mode 120000 index 000000000..ce2a6be54 --- /dev/null +++ b/bin/pydoc3.13 @@ -0,0 +1 @@ +.python3-3.13.2.pkg \ No newline at end of file diff --git a/bin/python b/bin/python new file mode 120000 index 000000000..ce2a6be54 --- /dev/null +++ b/bin/python @@ -0,0 +1 @@ +.python3-3.13.2.pkg \ No newline at end of file diff --git a/bin/python3 b/bin/python3 new file mode 120000 index 000000000..ce2a6be54 --- /dev/null +++ b/bin/python3 @@ -0,0 +1 @@ +.python3-3.13.2.pkg \ No newline at end of file diff --git a/bin/python3-config b/bin/python3-config new file mode 120000 index 000000000..ce2a6be54 --- /dev/null +++ b/bin/python3-config @@ -0,0 +1 @@ +.python3-3.13.2.pkg \ No newline at end of file diff --git a/bin/python3.13 b/bin/python3.13 new file mode 120000 index 000000000..ce2a6be54 --- /dev/null +++ b/bin/python3.13 @@ -0,0 +1 @@ +.python3-3.13.2.pkg \ No newline at end of file diff --git a/bin/python3.13-config b/bin/python3.13-config new file mode 120000 index 000000000..ce2a6be54 --- /dev/null +++ b/bin/python3.13-config @@ -0,0 +1 @@ +.python3-3.13.2.pkg \ No newline at end of file diff --git a/bin/sqlc b/bin/sqlc new file mode 120000 index 000000000..1dcdccd62 --- /dev/null +++ b/bin/sqlc @@ -0,0 +1 @@ +.sqlc-1.28.0.pkg \ No newline at end of file diff --git a/buf.gen.yaml b/buf.gen.yaml new file mode 100644 index 000000000..8ad47cb21 --- /dev/null +++ b/buf.gen.yaml @@ -0,0 +1,23 @@ +version: v2 +plugins: + - local: protoc-gen-go + out: server/generated/grpc + opt: paths=source_relative + include_imports: true + - local: protoc-gen-connect-go + out: server/generated/grpc + opt: paths=source_relative + - local: protoc-gen-es + out: client/src/protoFleet/api/generated + include_imports: true + opt: target=ts +managed: + enabled: true + disable: + # Don't modify any files in buf.build/googleapis/googleapis + - module: buf.build/googleapis/googleapis + # Don't modify any files in buf.build/bufbuild/protovalidate + - module: buf.build/bufbuild/protovalidate + override: + - file_option: go_package_prefix + value: github.com/block/proto-fleet/server/generated/grpc diff --git a/buf.lock b/buf.lock new file mode 100644 index 000000000..cadea4df7 --- /dev/null +++ b/buf.lock @@ -0,0 +1,9 @@ +# Generated by buf. DO NOT EDIT. +version: v2 +deps: + - name: buf.build/bufbuild/protovalidate + commit: 8976f5be98c146529b1cc15cd2012b60 + digest: b5:5d513af91a439d9e78cacac0c9455c7cb885a8737d30405d0b91974fe05276d19c07a876a51a107213a3d01b83ecc912996cdad4cddf7231f91379079cf7488d + - name: buf.build/googleapis/googleapis + commit: 61b203b9a9164be9a834f58c37be6f62 + digest: b5:7811a98b35bd2e4ae5c3ac73c8b3d9ae429f3a790da15de188dc98fc2b77d6bb10e45711f14903af9553fa9821dff256054f2e4b7795789265bc476bec2f088c diff --git a/buf.yaml b/buf.yaml new file mode 100644 index 000000000..159686615 --- /dev/null +++ b/buf.yaml @@ -0,0 +1,13 @@ +# For details on buf.yaml configuration, visit https://buf.build/docs/configuration/v2/buf-yaml +version: v2 +lint: + use: + - STANDARD +breaking: + use: + - FILE +modules: + - path: proto +deps: + - buf.build/googleapis/googleapis + - buf.build/bufbuild/protovalidate diff --git a/client/.dockerignore b/client/.dockerignore new file mode 100644 index 000000000..3c3629e64 --- /dev/null +++ b/client/.dockerignore @@ -0,0 +1 @@ +node_modules diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 000000000..a312fe4ed --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,35 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist-ssr +*.local +.env + +# build directories +dist + +# temporary directories from build +src/protoOS/public +src/protoFleet/public + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Playwright test artifacts +test-results +playwright-report diff --git a/client/.npmrc b/client/.npmrc new file mode 100644 index 000000000..38f11c645 --- /dev/null +++ b/client/.npmrc @@ -0,0 +1 @@ +registry=https://registry.npmjs.org diff --git a/client/.prettierignore b/client/.prettierignore new file mode 100644 index 000000000..7205d83d4 --- /dev/null +++ b/client/.prettierignore @@ -0,0 +1,16 @@ +# Ignore artifacts: +build +coverage +dist + +# Ignore all HTML files: +*.html + +# Ignore node_modules +node_modules + +# Ignore pnpm lock file +pnpm-lock.yaml + +# Ignore CLAUDE.md documentation +CLAUDE.md \ No newline at end of file diff --git a/client/.prettierrc.js b/client/.prettierrc.js new file mode 100644 index 000000000..0b9132a4d --- /dev/null +++ b/client/.prettierrc.js @@ -0,0 +1,15 @@ +export default { + semi: true, + useTabs: false, + trailingComma: "all", + singleQuote: false, + printWidth: 120, + tabWidth: 2, + bracketSpacing: true, + bracketSameLine: false, + endOfLine: "lf", + arrowParens: "always", + jsxSingleQuote: false, + plugins: ["prettier-plugin-tailwindcss"], + tailwindStylesheet: "./src/shared/styles/index.css", +}; diff --git a/client/.storybook/main.ts b/client/.storybook/main.ts new file mode 100644 index 000000000..12a6645a9 --- /dev/null +++ b/client/.storybook/main.ts @@ -0,0 +1,9 @@ +const config = { + stories: ["../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], + addons: ["@storybook/addon-actions", "@storybook/addon-docs"], + framework: { + name: "@storybook/react-vite", + options: {}, + }, +}; +export default config; diff --git a/client/.storybook/preview-body.html b/client/.storybook/preview-body.html new file mode 100644 index 000000000..c9c6b44aa --- /dev/null +++ b/client/.storybook/preview-body.html @@ -0,0 +1,13 @@ + + + + + + diff --git a/client/.storybook/preview.tsx b/client/.storybook/preview.tsx new file mode 100644 index 000000000..c1fc2e538 --- /dev/null +++ b/client/.storybook/preview.tsx @@ -0,0 +1,82 @@ +/* eslint-disable react-refresh/only-export-components */ +import React, { ComponentType, useEffect, useMemo } from "react"; +import { createMemoryRouter, RouterProvider } from "react-router-dom"; +import type { Preview } from "@storybook/react-vite"; +import "../src/shared/styles/index.css"; + +import { spyOn } from "storybook/test"; + +export const beforeEach = () => { + spyOn(console, "log").mockName("console.log"); + spyOn(console, "warn").mockName("console.warn"); +}; + +const ThemeWrapper = ({ theme, children }: { theme: string; children: React.ReactNode }) => { + useEffect(() => { + document.body.setAttribute("data-theme", theme); + }, [theme]); + return <>{children}; +}; + +const StoryRouter = ({ Story }: { Story: ComponentType }) => { + const router = useMemo(() => createMemoryRouter([{ path: "*", element: }]), [Story]); + + return ; +}; + +export const decorators = [ + (Story: ComponentType, context: { globals: { theme?: string }; parameters: { withRouter?: boolean } }) => { + const theme = context.globals.theme || "light"; + + if (context.parameters.withRouter === false) { + return ( + + + + ); + } + + return ( + + + + ); + }, +]; + +const preview: Preview = { + globalTypes: { + theme: { + description: "Theme", + toolbar: { + title: "Theme", + icon: "mirror", + items: [ + { value: "light", title: "Light", icon: "sun" }, + { value: "dark", title: "Dark", icon: "moon" }, + ], + dynamicTitle: true, + }, + }, + }, + initialGlobals: { + theme: "light", + }, + parameters: { + actions: { argTypesRegex: "^on[A-Z].*" }, + layout: "fullscreen", + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + options: { + storySort: { + order: ["Foundation", "Shared", "ProtoOS", "Proto Fleet", "*"], + }, + }, + }, +}; + +export default preview; diff --git a/client/README.md b/client/README.md new file mode 100644 index 000000000..3a806989b --- /dev/null +++ b/client/README.md @@ -0,0 +1,218 @@ +# Client + +## Overview + +This directory contains two React applications and a shared component library: + +- **ProtoOS**: Mining dashboard UI served by the miner's embedded API server (single-miner view) +- **ProtoFleet**: Fleet management UI for managing multiple miners (fleet-wide view) +- **Shared**: Common UI components used by both applications + +### Tech Stack + +- **React 19** with TypeScript +- **Vite 7** for builds and dev server +- **Zustand** for state management with Immer middleware +- **React Router 7** for routing +- **Tailwind CSS 4** for styling +- **Vitest** and Testing Library for testing +- **Storybook 10** for component documentation +- **Recharts** for data visualization +- **Motion** (Framer Motion) for animations + +## Directory Layout + +``` +client +├── .storybook # Storybook configuration +├── dist # Compiled production output +│ ├── protoFleet # ProtoFleet build output +│ └── protoOS # ProtoOS build output +├── public # Favicon and static assets +├── scripts # Development scripts +├── src +│ ├── protoFleet # Fleet management UI source +│ │ └── index.html # ProtoFleet entry point +│ ├── protoOS # Mining dashboard UI source +│ │ └── index.html # ProtoOS entry point +│ └── shared # Shared components, hooks, and utilities +├── eslint.config.js # Linting rules +├── package.json # Dependencies and npm scripts +├── postcss.config.js # PostCSS/Tailwind configuration +├── tsconfig.json # TypeScript configuration +└── vite.config.ts # Vite multi-app build configuration +``` + +## Getting Started + +### Install dependencies + +```bash +npm install +``` + +### Start dev server + +```bash +# Start ProtoOS dev server +npm run dev:protoOS + +# Start ProtoFleet dev server +npm run dev:protoFleet + +# Access at http://localhost:5173 +``` + +### Proxy Setup + +Both apps require proxy configuration to route API requests to backend servers. Create a `.env` file in this directory: + +**ProtoOS**: + +``` +PROXY_URL=http://127.0.0.1:8000 +``` + +Routes `/api/v1` requests to the miner API server. The proxy URL can point to a locally running miner-api-server, a test node IP, or a mock data API server like [Stoplight](https://stoplight.io/mocks/proto-mining/mdk-api/656299768). + +**ProtoFleet**: + +``` +FLEET_PROXY_URL=http://127.0.0.1:4000 +``` + +Routes `/api-proxy` requests to the fleet server. If you are implementing a new API endpoint, you may need to add the path to `vite.config.ts`. + +## Building + +```bash +# Build both applications +npm run build + +# Build individual applications +npm run build:protoOS +npm run build:protoFleet + +# Preview production builds +npm run preview:protoOS +npm run preview:protoFleet +``` + +### Multi-App Build System + +Vite is configured with mode-based builds. Each app has its own `index.html` entry point in `src/{app}/` and builds to `dist/{app}/`. Always specify the mode when building: `vite build --mode protoOS`. + +## Testing + +```bash +# Run all tests +npm test + +# Run tests matching a pattern +npx vitest run + +# Watch mode for a specific file +npx vitest watch + +# Run tests in a specific directory +npx vitest run src/protoOS/features/kpis +``` + +## Code Quality + +```bash +# Lint code +npm run lint + +# Format code with Prettier +npm run format + +# Check formatting without writing +npm run format:check + +# Run Storybook for visual component testing +npm run storybook +``` + +## Architecture + +### State Management + +**ProtoOS** uses Zustand with a slice-based architecture (`useMinerStore`): + +- Hardware, Telemetry, UI, Auth, Miner Status, Mining Target, Network Info, System Info slices +- Key data types: `Measurement`, `MetricTelemetry`, `MetricTimeSeries` +- See `src/protoOS/store/README.md` for comprehensive documentation + +**ProtoFleet** uses Zustand with a slice-based architecture (`useFleetStore`): + +- Fleet, UI, Auth, Onboarding slices +- Fleet slice handles miner collection, device status counts, filtering, and streaming telemetry + +### API Integration + +**ProtoOS** — REST API with generated TypeScript client from `proto-rig-api/openapi/MDK-API.json`. Application code uses hooks in `src/protoOS/api/hooks/` which handle error handling, polling, and automatic store updates. Regenerate types with `npm run generate-api-types`. + +**ProtoFleet** — gRPC-Web with Connect-RPC. Generated TypeScript code in `src/protoFleet/api/generated/` from Protobuf definitions. Supports server-to-client streaming for real-time telemetry. Custom hooks in `src/protoFleet/api/`. + +### Import Rules + +Use the `@/` path alias for all absolute imports: + +```typescript +// Good +import { Button } from "@/shared/components/Button"; +import { useMinerStore } from "@/protoOS/store"; + +// Bad +import { Button } from "../../../shared/components/Button"; +``` + +Strict import boundaries: + +- `src/shared/` must never import from `src/protoOS` or `src/protoFleet` +- `src/protoOS` must never import from `src/protoFleet`, and vice versa + +### Component Organization + +Components follow a feature-based structure: + +``` +features/ +└── kpis/ + ├── components/ # Feature-specific components + ├── utils/ # Feature utilities + ├── types.ts # Feature types + └── index.ts # Public exports +``` + +- Components used within a single feature live in that feature's `components/` directory +- Components shared across features within one app live in `src/{app}/components/` +- Components shared across both apps live in `src/shared/components/` +- Shared components should be pure — consistent output given the same props + +### Shared Components + +Reusable components in `src/shared/components/` include: + +- **Layout**: Card, ContentHeader, Divider, BackgroundImage +- **Interactive**: Button, ButtonGroup, Dialog, Modal, DurationSelector, Toggle +- **Data Display**: Chart, DataNullState, Callout, Chip, StatusBadge +- **Forms**: Checkbox, Input, Select, TextArea +- **Feedback**: Spinner, ErrorBoundary, Toast + +All shared components have Storybook stories, support light/dark themes, and include TypeScript prop types. + +## Testing on Hardware + +1. Compile the UI: `npm run build` +2. Build the Linux image via GitHub Actions +3. Transfer the image to the control board's SD card +4. Connect the board via ethernet and access the UI at the board's IP address + +## Learn More + +- [React](https://react.dev/learn) +- [Vite](https://vitejs.dev/guide/) +- [Tailwind CSS](https://tailwindcss.com/docs/utility-first) +- [Recharts](https://release--63da8268a0da9970db6992aa.chromatic.com/?path=/docs/welcome--docs) diff --git a/client/e2eTests/protoFleet/.gitignore b/client/e2eTests/protoFleet/.gitignore new file mode 100644 index 000000000..3bae91a8f --- /dev/null +++ b/client/e2eTests/protoFleet/.gitignore @@ -0,0 +1,28 @@ + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ + +# Environment variables +.env +.env.local +.env*.local + +# Local test config (not committed) +config/test.config.local.ts + +# macOS +.DS_Store + +# Editor +.vscode/ +.idea/ + +# Lock files (optional - remove if you want to commit them) +package-lock.json +pnpm-lock.yaml +yarn.lock diff --git a/client/e2eTests/protoFleet/README.md b/client/e2eTests/protoFleet/README.md new file mode 100644 index 000000000..bbe0a34ad --- /dev/null +++ b/client/e2eTests/protoFleet/README.md @@ -0,0 +1,374 @@ +# End-to-End Tests + +This directory contains end-to-end (E2E) tests for the ProtoFleet client application using Playwright. + +## Overview + +The E2E test suite validates critical user workflows and functionality across the ProtoFleet application, including authentication, miner management, pool configuration, and settings management. + +## Getting Started + +### Prerequisites + +- All dependencies installed via `npm install` in the client directory +- ProtoFleet development environment and fake miners set up (usually with `just dev`) + +### Quick Start + +The test configuration is already set up with default values. Simply run: + +```bash +just test-e2e-fleet +``` + +This command will: + +- Install Playwright browsers automatically if needed +- Run all tests in desktop mode +- Generate an HTML report + +🔒 The default credentials are `admin` and `Pass123!` + +### Available Commands + +**Using justfile (recommended):** + +```bash +just test-e2e-fleet # Run all tests (desktop) +just test-e2e-fleet-ui # Run in interactive UI mode +just test-e2e-fleet-headed # Run with visible browser +just test-e2e-fleet-wip # Run only tests tagged @wip +``` + +**Using npm scripts:** + +```bash +npm run test:e2e # Run all tests (desktop) +npm run test:e2e:ui # Run in interactive UI mode +npm run test:e2e:headed # Run with visible browser +``` + +### Test Execution Strategy + +- **Pull Requests**: Run the full Fleet suite through parallel spec workers +- **Nightly Builds**: Run the full Fleet suite +- **Manual Runs**: Run the full Fleet suite by default + +In CI, spec files whose names start with two digits, such as `00-onboarding.spec.ts` and `01-miningPools.spec.ts`, are treated as setup specs. They are replayed first, in filename order, before each non-setup spec worker runs its assigned spec file. + +**Using Playwright directly:** + +```bash +cd e2eTests +npx playwright test --project=desktop # Desktop viewport (1920x1080) +npx playwright test --project=mobile # Mobile viewport (393x852) +npx playwright test --headed # See browser +npx playwright test --debug # Debug mode +npx playwright test --ui # Interactive UI +npx playwright test spec/auth.spec.ts # Run specific file +``` + +### Viewing Test Reports + +After running tests, view the HTML report: + +```bash +npx playwright show-report +``` + +The report includes: + +- Test results and execution times +- Screenshots (captured on failure) +- Videos (retained on failure) +- Traces (captured on first retry) + +### Configuration + +Test configuration is in `config/test.config.ts`: + +```typescript +export const testConfig = { + baseUrl: "http://localhost:5173", + users: { + admin: { + username: "admin", + password: "Pass123!", + }, + }, + testTimeout: 60000, + actionTimeout: 30000, +}; +``` + +### Desktop vs Mobile Testing + +The test suite supports both desktop and mobile viewports, configured in `playwright.config.ts`: + +- **Desktop**: 1920x1080 viewport (default) +- **Mobile**: 393x852 viewport (iPhone 14/15/16 Pro resolution) + +Switch between projects using the `--project` flag: + +```bash +npx playwright test --project=desktop +npx playwright test --project=mobile +``` + +## Tech Stack + +- **[Playwright](https://playwright.dev/)**: Modern end-to-end testing framework +- **TypeScript**: Type-safe test development +- **Page Object Model**: Organized, maintainable test structure + +## Project Structure + +``` +e2eTests/ +├── config/ # Test configuration files +│ └── test.config.ts # Base URL, user credentials, timeouts +├── fixtures/ # Playwright fixtures for dependency injection +│ └── pageFixtures.ts # Page object and helper fixtures +├── helpers/ # Reusable test helper classes +│ ├── commonSteps.ts # Common test workflows (login, navigation) +│ └── testDataHelper.ts # Test data generation utilities +├── pages/ # Page Object Model implementations +│ ├── base.ts # Base page class with common methods +│ ├── auth.ts # Authentication page objects +│ ├── home.ts # Home page objects +│ ├── miners.ts # Miners page objects +│ ├── addMiners.ts # Add miners page objects +│ ├── editPool.ts # Pool editor page objects +│ ├── newPoolModal.ts # New pool modal objects +│ ├── settings.ts # Settings page objects +│ ├── settingsSecurity.ts # Security settings page objects +│ ├── settingsTeam.ts # Team settings page objects +│ └── settingsPools.ts # Pool settings page objects +├── spec/ # Test specifications +│ ├── 00-onboarding.spec.ts # Initial setup and onboarding tests +│ ├── 01-miningPools.spec.ts # Pool configuration tests +│ ├── auth.spec.ts # Authentication tests +│ ├── minersActions.spec.ts # Miner management and actions tests +│ ├── securitySettings.spec.ts # Security settings tests +│ ├── generalSettings.spec.ts # General settings tests +│ ├── teamAccounts.spec.ts # Team account management tests +│ └── navigation.spec.ts # Navigation flow tests +├── playwright-report/ # Generated test reports (gitignored) +└── playwright.config.ts # Playwright configuration +``` + +## Writing Tests + +### Page Object Pattern + +Tests use the Page Object Model pattern to encapsulate page interactions: + +```typescript +// Example: pages/miners.ts +export class MinersPage extends BasePage { + async clickSelectAllCheckbox() { + await this.page.locator('[data-testid="select-all-checkbox"]').click(); + } + + async validateAmountOfMiners(expected: number) { + const miners = this.page.locator('[data-testid="miner-row"]'); + await expect(miners).toHaveCount(expected); + } +} +``` + +### Helper Classes + +Common test workflows are encapsulated in helper classes: + +```typescript +// Example: helpers/commonSteps.ts +export class CommonSteps { + async loginAsAdmin() { + await test.step("Login as admin", async () => { + await this.authPage.inputUsername(testConfig.users.admin.username); + await this.authPage.inputPassword(testConfig.users.admin.password); + await this.authPage.clickLogin(); + await this.authPage.validateLoggedIn(); + }); + } + + async goToMinersPage() { + await test.step("Navigate to miners page", async () => { + await this.minersPage.navigateToMinersPage(); + await this.minersPage.waitForMinersTitle(); + await this.minersPage.waitForMinersListToLoad(); + }); + } +} +``` + +### Using Fixtures + +Fixtures provide automatic dependency injection for page objects and helpers: + +```typescript +import { test } from "../fixtures/pageFixtures"; + +test("My test", async ({ authPage, minersPage, commonSteps }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + await minersPage.validateMiners(); +}); +``` + +Available fixtures: + +- `authPage` - Authentication page +- `homePage` - Home page +- `minersPage` - Miners page +- `addMinersPage` - Add miners page +- `settingsPage` - Settings page +- `settingsSecurityPage` - Security settings page +- `settingsTeamPage` - Team settings page +- `settingsPoolsPage` - Pool settings page +- `editPoolPage` - Pool editor page +- `newPoolModal` - New pool modal +- `commonSteps` - Common test workflows + +### Test Structure Example + +```typescript +test.describe("Feature Name", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test("should perform action", async ({ minersPage, commonSteps }) => { + // Arrange + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + // Act + await test.step("Perform action", async () => { + await minersPage.performAction(); + }); + + // Assert + await test.step("Validate result", async () => { + await minersPage.validateResult(); + }); + }); +}); +``` + +## Best Practices + +### Test Organization + +- Group related tests using `test.describe()` +- Use descriptive test names that explain the scenario +- Keep tests independent and idempotent +- Use `beforeEach` for common setup +- Use `test.step()` to organize test logic into readable sections +- Leverage `commonSteps` helper for frequently used workflows +- Number test files (00-, 01-) when execution order matters + +### Locator Strategy + +1. **Prefer data-testid attributes**: `page.locator('[data-testid="button-name"]')` +2. **Use semantic selectors**: `page.getByRole('button', { name: 'Submit' })` +3. **Avoid brittle selectors**: Don't rely on class names or DOM structure + +### Assertions + +- Use Playwright's built-in assertions with auto-waiting +- Validate expected states explicitly +- Include meaningful assertion messages when needed + +```typescript +await expect(element).toBeVisible(); +await expect(element).toHaveText("Expected text"); +await expect(page).toHaveURL(/.*\/expected-path/); +``` + +### API Validation + +- Use `page.waitForRequest()` and `page.waitForResponse()` to validate API calls +- Verify request payloads and response status codes +- Ensure critical operations complete successfully at the network level + +```typescript +const responsePromise = page.waitForResponse( + (response) => response.url().includes("/api/reboot") && response.status() === 200, +); +await minersPage.clickRebootButton(); +await responsePromise; +``` + +### Error Handling + +- Tests automatically capture screenshots and videos on failure +- Use traces for debugging complex failures +- Add explicit waits for dynamic content +- Validate both UI state and API responses for critical operations + +### Code Quality + +- Disable `playwright/expect-expect` ESLint rule only when page objects handle assertions +- Keep page objects focused on single pages or components +- Reuse common functionality in `BasePage` + +## Troubleshooting + +### Tests fail to connect to application + +- Ensure the client is running on `http://localhost:5173` +- Check that the server backend is running and accessible +- Verify virtual miners are running (if testing miner functionality) + +### Timeouts + +- Increase timeout in `config/test.config.ts` +- Check for slow network or server responses +- Verify selectors are correct and elements are rendered + +### Browser issues + +- Reinstall browsers: `npx playwright install --force` +- Check Playwright version compatibility +- Clear browser state between test runs + +## CI/CD Integration + +E2E tests run automatically in GitHub Actions: + +- **Triggers**: Pull requests affecting e2e tests, daily at 7 AM UTC, manual dispatch +- **Matrix**: Tests run on both `desktop` and `mobile` projects +- **Environment**: Ubuntu with Docker, TimescaleDB, and 12 fake miners +- **Reporting**: HTML reports and GitHub annotations + +See [`.github/workflows/protofleet-e2e-tests.yml`](/.github/workflows/protofleet-e2e-tests.yml) for full workflow configuration. + +### CI Test Execution + +The workflow: + +1. Builds ProtoFleet client (both ProtoOS and ProtoFleet apps) +2. Starts TimescaleDB service +3. Builds and runs 12 fake miners via Docker Compose +4. Runs backend server +5. Executes Playwright tests with `--project=desktop` or `--project=mobile` +6. Uploads test reports and traces as artifacts + +## Additional Resources + +- [Playwright Documentation](https://playwright.dev/) +- [Playwright Best Practices](https://playwright.dev/docs/best-practices) +- [Page Object Model Pattern](https://playwright.dev/docs/pom) +- [Debugging Tests](https://playwright.dev/docs/debug) + +## Contributing + +When adding new tests: + +1. Create appropriate page objects in `pages/` +2. Add fixtures if needed in `fixtures/` +3. Write descriptive test cases in `spec/` +4. Ensure tests pass locally before committing +5. Follow existing patterns and conventions diff --git a/client/e2eTests/protoFleet/config/test.config.defaults.ts b/client/e2eTests/protoFleet/config/test.config.defaults.ts new file mode 100644 index 000000000..bca9a3af4 --- /dev/null +++ b/client/e2eTests/protoFleet/config/test.config.defaults.ts @@ -0,0 +1,28 @@ +export const defaultTestConfig = { + baseUrl: "http://localhost:5173", + + /** + * Execution target for environment-specific behavior. + * - fake: local/dev environment using fake miners (default) + * - real: environment backed by real miners + */ + target: "fake" as "fake" | "real", + + users: { + admin: { + username: "admin", + password: "Pass123!", + }, + }, + + miners: { + username: "root", + password: "root", + }, + + testTimeout: 180000, + actionTimeout: 30000, + interval: 500, +}; + +export type TestConfig = typeof defaultTestConfig; diff --git a/client/e2eTests/protoFleet/config/test.config.local.example.ts b/client/e2eTests/protoFleet/config/test.config.local.example.ts new file mode 100644 index 000000000..29460eb13 --- /dev/null +++ b/client/e2eTests/protoFleet/config/test.config.local.example.ts @@ -0,0 +1,26 @@ +import type { TestConfig } from "./test.config.defaults"; + +/** + * Example local test configuration. + * HOW TO USE: + * Copy this file as test.config.local.ts and customize for your local environment. + * The local config file is gitignored and will not be committed. + * + * You can override any property from the default config. + * Your IDE will provide autocomplete for all available options. + */ +export const localTestConfig: Partial = { + // Example: Switch environment target (real miners skip auth flows) + // target: "real", + + // Example: Force the local fake-miners environment explicitly + // target: "fake", + + // Example: Override admin credentials + users: { + admin: { + username: "your-username", + password: "your-password", + }, + }, +}; diff --git a/client/e2eTests/protoFleet/config/test.config.ts b/client/e2eTests/protoFleet/config/test.config.ts new file mode 100644 index 000000000..6d63332dd --- /dev/null +++ b/client/e2eTests/protoFleet/config/test.config.ts @@ -0,0 +1,65 @@ +import { defaultTestConfig, type TestConfig } from "./test.config.defaults"; + +type E2ETarget = TestConfig["target"]; + +const fakeBaseUrl = "http://localhost:5173"; +const realBaseUrl = "http://localhost:8080"; + +function parseOptionalTarget(value: string | undefined): E2ETarget | undefined { + if (!value) { + return undefined; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === "fake") { + return "fake"; + } + if (normalized === "real") { + return "real"; + } + + return undefined; +} + +let localConfig: Partial = {}; +try { + // Try to import local config if it exists + // @ts-expect-error - Local config file may not exist + const module = await import("./test.config.local"); + localConfig = module.localTestConfig || {}; +} catch { + // Local config doesn't exist, use defaults only +} + +// Merge default config with local overrides +const mergedConfig: TestConfig = { + ...defaultTestConfig, + ...localConfig, + users: { + ...defaultTestConfig.users, + ...localConfig.users, + }, + miners: { + ...defaultTestConfig.miners, + ...localConfig.miners, + }, +}; + +const rawTarget = process.env.E2E_TARGET; +const targetFromEnv = parseOptionalTarget(rawTarget); +if (rawTarget && !targetFromEnv) { + throw new Error(`Invalid E2E_TARGET value "${rawTarget}". Expected "fake" or "real".`); +} +const resolvedTarget: E2ETarget = targetFromEnv ?? mergedConfig.target; + +// Intentionally derived from target only (no base URL overrides). +const resolvedBaseUrl = resolvedTarget === "real" ? realBaseUrl : fakeBaseUrl; + +export const testConfig: TestConfig = { + ...mergedConfig, + baseUrl: resolvedBaseUrl, + target: resolvedTarget, +}; + +export const DEFAULT_TIMEOUT = testConfig.actionTimeout; +export const DEFAULT_INTERVAL = testConfig.interval; diff --git a/client/e2eTests/protoFleet/fixtures/pageFixtures.ts b/client/e2eTests/protoFleet/fixtures/pageFixtures.ts new file mode 100644 index 000000000..180260f51 --- /dev/null +++ b/client/e2eTests/protoFleet/fixtures/pageFixtures.ts @@ -0,0 +1,96 @@ +// NOTE: eslint incorrectly identifies 'use' as react hook +/* eslint-disable react-hooks/rules-of-hooks */ +import { test as base } from "@playwright/test"; +import { CommonSteps } from "../helpers/commonSteps"; +import { AddMinersPage } from "../pages/addMiners"; +import { AuthPage } from "../pages/auth"; +import { LoginModalComponent } from "../pages/components/loginModal"; +import { EditPoolPage } from "../pages/editPool"; +import { GroupsPage } from "../pages/groups"; +import { HomePage } from "../pages/home"; +import { MinersPage } from "../pages/miners"; +import { NewPoolModalPage } from "../pages/newPoolModal"; +import { RacksPage } from "../pages/racks"; +import { SettingsPage } from "../pages/settings"; +import { SettingsApiKeysPage } from "../pages/settingsApiKeys"; +import { SettingsFirmwarePage } from "../pages/settingsFirmware"; +import { SettingsPoolsPage } from "../pages/settingsPools"; +import { SettingsSchedulesPage } from "../pages/settingsSchedules"; +import { SettingsSecurityPage } from "../pages/settingsSecurity"; +import { SettingsTeamPage } from "../pages/settingsTeam"; + +type PageFixtures = { + authPage: AuthPage; + homePage: HomePage; + minersPage: MinersPage; + groupsPage: GroupsPage; + racksPage: RacksPage; + addMinersPage: AddMinersPage; + settingsPage: SettingsPage; + settingsFirmwarePage: SettingsFirmwarePage; + settingsApiKeysPage: SettingsApiKeysPage; + settingsSchedulesPage: SettingsSchedulesPage; + settingsSecurityPage: SettingsSecurityPage; + settingsTeamPage: SettingsTeamPage; + settingsPoolsPage: SettingsPoolsPage; + editPoolPage: EditPoolPage; + newPoolModal: NewPoolModalPage; + loginModal: LoginModalComponent; + commonSteps: CommonSteps; +}; + +export const test = base.extend({ + authPage: async ({ page, isMobile }, use) => { + await use(new AuthPage(page, isMobile)); + }, + homePage: async ({ page, isMobile }, use) => { + await use(new HomePage(page, isMobile)); + }, + minersPage: async ({ page, isMobile }, use) => { + await use(new MinersPage(page, isMobile)); + }, + groupsPage: async ({ page, isMobile }, use) => { + await use(new GroupsPage(page, isMobile)); + }, + racksPage: async ({ page, isMobile }, use) => { + await use(new RacksPage(page, isMobile)); + }, + addMinersPage: async ({ page, isMobile }, use) => { + await use(new AddMinersPage(page, isMobile)); + }, + settingsPage: async ({ page, isMobile }, use) => { + await use(new SettingsPage(page, isMobile)); + }, + settingsFirmwarePage: async ({ page, isMobile }, use) => { + await use(new SettingsFirmwarePage(page, isMobile)); + }, + settingsApiKeysPage: async ({ page, isMobile }, use) => { + await use(new SettingsApiKeysPage(page, isMobile)); + }, + settingsSchedulesPage: async ({ page, isMobile }, use) => { + await use(new SettingsSchedulesPage(page, isMobile)); + }, + settingsSecurityPage: async ({ page, isMobile }, use) => { + await use(new SettingsSecurityPage(page, isMobile)); + }, + settingsTeamPage: async ({ page, isMobile }, use) => { + await use(new SettingsTeamPage(page, isMobile)); + }, + settingsPoolsPage: async ({ page, isMobile }, use) => { + await use(new SettingsPoolsPage(page, isMobile)); + }, + editPoolPage: async ({ page, isMobile }, use) => { + await use(new EditPoolPage(page, isMobile)); + }, + newPoolModal: async ({ page, isMobile }, use) => { + await use(new NewPoolModalPage(page, isMobile)); + }, + loginModal: async ({ page, isMobile }, use) => { + await use(new LoginModalComponent(page, isMobile)); + }, + commonSteps: async ({ authPage, minersPage }, use) => { + await use(new CommonSteps(authPage, minersPage)); + }, +}); + +export const expect = test.expect; diff --git a/client/e2eTests/protoFleet/helpers/commonSteps.ts b/client/e2eTests/protoFleet/helpers/commonSteps.ts new file mode 100644 index 000000000..c9065edad --- /dev/null +++ b/client/e2eTests/protoFleet/helpers/commonSteps.ts @@ -0,0 +1,28 @@ +import { test } from "@playwright/test"; +import { testConfig } from "../config/test.config"; +import { AuthPage } from "../pages/auth"; +import { MinersPage } from "../pages/miners"; + +export class CommonSteps { + constructor( + private authPage: AuthPage, + private minersPage: MinersPage, + ) {} + + async loginAsAdmin() { + await test.step("Login as admin", async () => { + await this.authPage.inputUsername(testConfig.users.admin.username); + await this.authPage.inputPassword(testConfig.users.admin.password); + await this.authPage.clickLogin(); + await this.authPage.validateLoggedIn(); + }); + } + + async goToMinersPage() { + await test.step("Navigate to miners page", async () => { + await this.minersPage.navigateToMinersPage(); + await this.minersPage.waitForMinersTitle(); + await this.minersPage.waitForMinersListToLoad(); + }); + } +} diff --git a/client/e2eTests/protoFleet/helpers/minerModels.ts b/client/e2eTests/protoFleet/helpers/minerModels.ts new file mode 100644 index 000000000..06c3db3ad --- /dev/null +++ b/client/e2eTests/protoFleet/helpers/minerModels.ts @@ -0,0 +1,2 @@ +export const PROTO_RIG_MODEL = "Rig"; +export const PROTO_RIG_DISPLAY_NAME = "Proto Rig"; diff --git a/client/e2eTests/protoFleet/helpers/testDataHelper.ts b/client/e2eTests/protoFleet/helpers/testDataHelper.ts new file mode 100644 index 000000000..b95f84046 --- /dev/null +++ b/client/e2eTests/protoFleet/helpers/testDataHelper.ts @@ -0,0 +1,19 @@ +export function generateRandomText(prefix: string): string { + const randomCode = Math.random().toString(36).substring(2, 9); + return `${prefix}_${randomCode}`; +} + +export function generateRandomUsername(): string { + return generateRandomText("username"); +} + +// Issue icon IDs for miner issues column +export const IssueIcon = { + CONTROL_BOARD: "control-board-icon", + HASH_BOARD: "hashboard-icon", + PSU: "lightning-alt-icon", + FAN: "fan-icon", + GENERAL_ALERT: "alert-icon", +} as const; + +export type IssueIconId = (typeof IssueIcon)[keyof typeof IssueIcon]; diff --git a/client/e2eTests/protoFleet/pages/addMiners.ts b/client/e2eTests/protoFleet/pages/addMiners.ts new file mode 100644 index 000000000..850c7d75e --- /dev/null +++ b/client/e2eTests/protoFleet/pages/addMiners.ts @@ -0,0 +1,144 @@ +import { expect, type Locator } from "@playwright/test"; +import { PROTO_RIG_DISPLAY_NAME } from "../helpers/minerModels"; +import { BasePage } from "./base"; + +export class AddMinersPage extends BasePage { + async clickFindMinersInNetwork() { + await this.clickIn("Find miners", "section-scan-network"); + } + + async clickFindMinersByIp() { + await this.clickIn("Find miners", "section-search-by-ip"); + } + + async inputMinerIp(ipAddresses: string) { + await this.page.locator('//textarea[@id="ipAddresses"]').fill(ipAddresses); + } + + async clickChooseMiners() { + await this.clickButton("Choose miners"); + } + + async clickSelectAllCheckboxInModal() { + await this.page.getByTestId("modal").getByTestId("select-all-checkbox").click(); + } + + async clickSelectNone() { + await this.clickButton("Select none"); + } + + async getMinerIpAddressByIndex(index: number): Promise { + const rows = this.page.getByTestId("modal").getByTestId("list-body").locator("tr"); + const row = rows.nth(index); + return await row.getByTestId("ipAddress").innerText(); + } + + async getMinerRowByIp(ipAddress: string): Promise { + return this.page + .getByTestId("modal") + .locator(`//tr[child::*[@data-testid="ipAddress" and descendant::text()='${ipAddress}']]`); + } + + async clickMinerCheckbox(ipAddress: string) { + const minerRow = await this.getMinerRowByIp(ipAddress); + await minerRow.locator('input[type="checkbox"]').click(); + } + + async clickDone() { + await this.clickButton("Done"); + } + + async clickContinueWithXMiners(minerCount: number) { + await this.page.getByRole("button", { name: `Continue with ${minerCount} miners` }).click(); + } + + async clickContinueWithSelectedMiners() { + await this.page.getByRole("button", { name: /Continue with \d+ miner(s)?/ }).click(); + } + + async waitForFoundMinersList() { + const foundMinersList = this.page.getByTestId("found-miners-list"); + await expect(foundMinersList).toBeVisible(); + } + + async getFoundMinersCount(): Promise { + const minerRows = this.page.getByTestId("miner-model-row"); + return await minerRows.count(); + } + + async clickHeaderIconButton() { + await this.page.getByTestId("header-icon-button").click(); + } + + async validateOneMinerWasFoundByIp() { + const foundMessage = this.page.getByText("1 miners found on your network"); + await expect(foundMessage).toBeVisible(); + + const minerRows = this.page.getByTestId("miner-model-row"); + await expect(minerRows).toHaveCount(1); + + const firstMinerRow = minerRows.first(); + await expect(firstMinerRow).toContainText(PROTO_RIG_DISPLAY_NAME); + await expect(firstMinerRow).toContainText("1 miners"); + + const continueButton = this.page.getByRole("button", { name: "Continue with 1 miners" }); + await expect(continueButton).toBeVisible(); + } + + async validateValidationErrorDialogIsVisible() { + const dialog = this.page.getByTestId("validation-error-dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText("Some entries not recognized")).toBeVisible(); + } + + async validateValidationErrorDialogIsClosed() { + await expect(this.page.getByTestId("validation-error-dialog")).toBeHidden(); + } + + async validateInvalidIpAddressesInDialog(entries: string[]) { + const dialog = this.page.getByTestId("validation-error-dialog"); + await expect(dialog.getByText("Invalid IP addresses")).toBeVisible(); + for (const entry of entries) { + await expect(dialog.getByText(entry)).toBeVisible(); + } + } + + async validateInvalidIpRangesInDialog(entries: string[]) { + const dialog = this.page.getByTestId("validation-error-dialog"); + await expect(dialog.getByText("Invalid IP ranges")).toBeVisible(); + for (const entry of entries) { + await expect(dialog.getByText(entry)).toBeVisible(); + } + } + + async validateInvalidSubnetsInDialog(entries: string[]) { + const dialog = this.page.getByTestId("validation-error-dialog"); + await expect(dialog.getByText("Invalid subnet blocks")).toBeVisible(); + for (const entry of entries) { + await expect(dialog.getByText(entry)).toBeVisible(); + } + } + + async clickBackToEditing() { + await this.page.getByTestId("validation-error-dialog").getByRole("button", { name: "Back to editing" }).click(); + } + + async clickContinueAnyway() { + await this.page.getByTestId("validation-error-dialog").getByRole("button", { name: "Continue anyway" }).click(); + } + + async validateContinueAnywayButtonNotVisible() { + const dialog = this.page.getByTestId("validation-error-dialog"); + await expect(dialog.getByRole("button", { name: "Continue anyway" })).toBeHidden(); + } + + async validateContinueAnywayButtonVisible() { + const dialog = this.page.getByTestId("validation-error-dialog"); + await expect(dialog.getByRole("button", { name: "Continue anyway" })).toBeVisible(); + } + + async validateTextareaErrorContains(text: string) { + const errorElement = this.page.getByTestId("ipAddresses-validation-error"); + await expect(errorElement).toContainText(text); + } +} diff --git a/client/e2eTests/protoFleet/pages/auth.ts b/client/e2eTests/protoFleet/pages/auth.ts new file mode 100644 index 000000000..45f9ea695 --- /dev/null +++ b/client/e2eTests/protoFleet/pages/auth.ts @@ -0,0 +1,64 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "./base"; + +export class AuthPage extends BasePage { + async inputUsername(username: string) { + await this.page.locator(`//input[@id='username']`).fill(username); + } + + async inputPassword(password: string) { + await this.page.locator(`//input[@id='password']`).fill(password); + } + + async clickLogin() { + await this.page.locator(`//button[@data-testid="login-button"]`).click(); + } + + async validateRedirectedToAuth() { + await expect(this.page).toHaveURL(/.*\/auth/); + } + + async inputNewPassword(password: string) { + await this.page.locator(`//input[@id='newPassword']`).fill(password); + } + + async inputConfirmPassword(password: string) { + await this.page.locator(`//input[@id='confirmPassword']`).fill(password); + } + + async clickContinue() { + await this.clickButton("Continue"); + } + + async clickLoginButton() { + await this.clickButton("Login"); + } + + async clickPasswordVisibilityToggle() { + await this.page.locator(`//*[@data-testid="eye-icon"]`).click(); + } + + async validateInvalidCredentials() { + await expect(this.page.getByText("Invalid credentials entered.")).toBeVisible(); + } + + async validateUpdatePasswordTitle() { + await this.validateTitle("Update Your Password"); + } + + async validatePasswordSaved() { + await this.validateTitle("Password saved"); + } + + async clickCreateAccount() { + await this.clickButton("Create an account"); + } + + async validateCreateCredentialsPrompt() { + await expect(this.page.getByText("Create your username and password")).toBeVisible(); + } + + async clickGetStarted() { + await this.clickButton("Get started"); + } +} diff --git a/client/e2eTests/protoFleet/pages/base.ts b/client/e2eTests/protoFleet/pages/base.ts new file mode 100644 index 000000000..95e7ad417 --- /dev/null +++ b/client/e2eTests/protoFleet/pages/base.ts @@ -0,0 +1,246 @@ +import { expect, Page } from "@playwright/test"; +import { DEFAULT_TIMEOUT, testConfig } from "../config/test.config"; + +export class BasePage { + constructor( + protected page: Page, + protected isMobile: boolean = false, + ) {} + + async reloadPage() { + await this.page.reload(); + } + + async validateLoggedIn(timeout: number = DEFAULT_TIMEOUT) { + if (this.isMobile) { + await expect(this.page.getByTestId("navigation-menu-button")).toBeVisible({ timeout }); + } else { + await expect(this.page.getByTestId("logout-button")).toBeVisible({ timeout }); + } + } + + async logout() { + await this.clickNavigationMenuIfMobile(); + await this.page.getByTestId("logout-button").click(); + } + + async validateTitle(expectedTitle: string) { + const titleLocator = this.page.locator(`//*[contains(@class,'heading')][text()='${expectedTitle}']`); + await expect(titleLocator).toBeVisible(); + } + + async validateTitleInModal(expectedTitle: string) { + const titleLocator = this.page.locator( + `//*[@data-testid='modal']//*[contains(@class,'heading')][text()='${expectedTitle}']`, + ); + await expect(titleLocator).toBeVisible(); + } + + async validateTitleNotVisible(expectedTitle: string) { + const titleLocator = this.page.locator(`//*[contains(@class,'heading')][text()='${expectedTitle}']`); + await expect(titleLocator).toBeHidden(); + } + + async validateTitleInModalNotVisible(expectedTitle: string) { + const titleLocator = this.page.locator( + `//*[@data-testid='modal']//*[contains(@class,'heading')][text()='${expectedTitle}']`, + ); + await expect(titleLocator).toBeHidden(); + } + + async validateTextIsVisible(text: string) { + await expect(this.page.getByText(text)).toBeVisible(); + } + + async validateTextInToast(text: string) { + const toast = this.page.getByTestId("toast").getByText(text); + await expect(toast).toBeVisible(); + } + + async validateTextInToastGroup(text: string) { + const groupedHeaderMessage = this.page.getByTestId("grouped-toaster-header").getByText(text).first(); + const groupedBodyMessage = this.page.getByTestId("toaster-container").getByTestId("toast").getByText(text).first(); + + await expect + .poll( + async () => + (await groupedHeaderMessage.isVisible().catch(() => false)) || + (await groupedBodyMessage.isVisible().catch(() => false)), + { + timeout: DEFAULT_TIMEOUT, + }, + ) + .toBe(true); + } + + async dismissToast() { + const toast = this.page.getByTestId("toaster-container"); + const dismissButton = this.page.getByRole("button", { name: "Dismiss" }); + if (!(await dismissButton.isVisible())) { + await toast.click(); + } + await toast.getByRole("button", { name: "Dismiss" }).click(); + } + + async validateTextInModal(text: string) { + await expect(this.page.getByTestId("modal").getByText(text)).toBeVisible(); + } + + async validateTextNotInModal(text: string) { + await expect(this.page.getByTestId("modal").getByText(text)).toBeHidden(); + } + + async validateButtonIsVisible(text: string) { + await expect(this.page.getByRole("button", { name: text })).toBeVisible(); + } + + async clickNavigationMenuIfMobile() { + if (this.isMobile) { + await this.page.getByTestId("navigation-menu-button").click(); + } + } + + async clickExpandSettingsIfMobile() { + if (this.isMobile && !this.page.url().includes("/settings")) { + await this.page.getByTestId("navigation-menu").getByText("Settings").click(); + } + } + + async navigateToHomePage() { + await this.clickNavigationMenuIfMobile(); + await this.page.getByTestId("navigation-menu").locator('a[href="/"]').click(); + await expect(this.page).toHaveURL(/.*\/$/); + } + + async navigateToMinersPage() { + await this.clickNavigationMenuIfMobile(); + await this.page.getByTestId("navigation-menu").locator('a[href="/miners"]').click(); + await expect(this.page).toHaveURL(/.*\/miners/); + } + + async navigateToGroupsPage() { + await this.clickNavigationMenuIfMobile(); + await this.page.getByTestId("navigation-menu").locator('a[href="/groups"]').click(); + await expect(this.page).toHaveURL(/.*\/groups/); + } + + async navigateToRacksPage() { + await this.clickNavigationMenuIfMobile(); + await this.page.getByTestId("navigation-menu").locator('a[href="/racks"]').click(); + await expect(this.page).toHaveURL(/.*\/racks/); + } + + async navigateToSettingsPage() { + await this.clickNavigationMenuIfMobile(); + await this.clickExpandSettingsIfMobile(); + if (this.isMobile) { + await this.page.getByTestId("navigation-menu").locator('a[href="/settings/general"]').click(); + } else { + await this.page.getByTestId("navigation-menu").locator('a[href="/settings"]').click(); + } + await expect(this.page).toHaveURL(/.*\/settings/); + } + + async navigateSettingsIfDesktop() { + // desktop can't navigate directly to subpages of settings + if (!this.isMobile && !this.page.url().includes("/settings")) { + await this.navigateToSettingsPage(); + } + } + + async navigateToSecuritySettings() { + await this.clickNavigationMenuIfMobile(); + await this.clickExpandSettingsIfMobile(); + await this.navigateSettingsIfDesktop(); + await this.page.getByTestId("secondary-nav").locator('a[href="/settings/security"]').click(); + await expect(this.page).toHaveURL(/.*\/settings\/security/); + } + + async navigateToTeamSettings() { + await this.clickNavigationMenuIfMobile(); + await this.clickExpandSettingsIfMobile(); + await this.navigateSettingsIfDesktop(); + await this.page.getByTestId("secondary-nav").locator('a[href="/settings/team"]').click(); + await expect(this.page).toHaveURL(/.*\/settings\/team/); + } + + async navigateToMiningPoolsSettings() { + await this.clickNavigationMenuIfMobile(); + await this.clickExpandSettingsIfMobile(); + await this.navigateSettingsIfDesktop(); + await this.page.getByTestId("secondary-nav").locator('a[href="/settings/mining-pools"]').click(); + await expect(this.page).toHaveURL(/.*\/settings\/mining-pools/); + } + + async navigateToFirmwareSettings() { + await this.clickNavigationMenuIfMobile(); + await this.clickExpandSettingsIfMobile(); + await this.navigateSettingsIfDesktop(); + await this.page.getByTestId("secondary-nav").locator('a[href="/settings/firmware"]').click(); + await expect(this.page).toHaveURL(/.*\/settings\/firmware/); + } + + async navigateToApiKeysSettings() { + await this.clickNavigationMenuIfMobile(); + await this.clickExpandSettingsIfMobile(); + await this.navigateSettingsIfDesktop(); + await this.page.getByTestId("secondary-nav").locator('a[href="/settings/api-keys"]').click(); + await expect(this.page).toHaveURL(/.*\/settings\/api-keys/); + } + + async navigateToSchedulesSettings() { + await this.clickNavigationMenuIfMobile(); + await this.clickExpandSettingsIfMobile(); + await this.navigateSettingsIfDesktop(); + await this.page.getByTestId("secondary-nav").locator('a[href="/settings/schedules"]').click(); + await expect(this.page).toHaveURL(/.*\/settings\/schedules/); + } + + async clickButton(text: string) { + await this.page.getByRole("button", { name: text, disabled: false, exact: true }).click(); + } + + async clickUntilNotVisible(text: string) { + const button = this.page.getByRole("button", { name: text, disabled: false, exact: true }); + + await expect(button).toBeVisible(); + await expect(async () => { + const isVisible = await button.isVisible(); + if (isVisible) { + await button.click(); + throw new Error("Button still visible, looping until it is not or the time runs out"); + } + }).toPass({ timeout: DEFAULT_TIMEOUT, intervals: [100] }); + } + + async clickIn(text: string, testId: string) { + await this.page.getByTestId(testId).getByRole("button", { name: text, disabled: false, exact: true }).click(); + } + + async validateModalIsOpen() { + await expect(this.page.getByTestId("modal")).toBeVisible(); + } + + async validateModalIsClosed() { + await expect(this.page.getByTestId("modal")).toBeHidden(); + } + + async clickSaveInModal() { + await this.clickIn("Save", "modal"); + } + + // Helper method to try an action with timeout and return success/failure + // Useful in cases where we are not sure in what state the system is at a particular moment, e.g. during cleanup + async tryAction(action: () => Promise, timeoutMs: number = 3000): Promise { + const originalTimeout = testConfig.actionTimeout; + this.page.setDefaultTimeout(timeoutMs); + try { + await action(); + return true; + } catch { + return false; + } finally { + this.page.setDefaultTimeout(originalTimeout); + } + } +} diff --git a/client/e2eTests/protoFleet/pages/components/loginModal.ts b/client/e2eTests/protoFleet/pages/components/loginModal.ts new file mode 100644 index 000000000..909eb8156 --- /dev/null +++ b/client/e2eTests/protoFleet/pages/components/loginModal.ts @@ -0,0 +1,16 @@ +import { testConfig } from "../../config/test.config"; +import { BasePage } from "../base"; + +export class LoginModalComponent extends BasePage { + async loginAsAdmin() { + const headingText = "Log in to update your pool settings"; + await this.validateTitleInModal(headingText); + const modal = this.page.getByTestId("modal"); + + await modal.locator("xpath=.//input[@id='username']").fill(testConfig.users.admin.username); + await modal.locator("xpath=.//input[@id='password']").fill(testConfig.users.admin.password); + await modal.getByRole("button", { name: "Continue" }).click(); + + await this.validateTitleInModalNotVisible(headingText); + } +} diff --git a/client/e2eTests/protoFleet/pages/components/modalMinerSelectionList.ts b/client/e2eTests/protoFleet/pages/components/modalMinerSelectionList.ts new file mode 100644 index 000000000..7e32ca7cd --- /dev/null +++ b/client/e2eTests/protoFleet/pages/components/modalMinerSelectionList.ts @@ -0,0 +1,95 @@ +import { expect, type Locator } from "@playwright/test"; +import { DEFAULT_INTERVAL, DEFAULT_TIMEOUT } from "../../config/test.config"; + +export class ModalMinerSelectionList { + constructor(private readonly root: Locator) {} + + private get rows(): Locator { + return this.root.getByTestId("list-row"); + } + + async waitForListToLoad({ allowEmpty = false }: { allowEmpty?: boolean } = {}) { + if (!allowEmpty) { + await expect(this.rows).not.toHaveCount(0); + } + + await expect(async () => { + const rowCount = await this.rows.count(); + await new Promise((resolve) => setTimeout(resolve, DEFAULT_INTERVAL)); + const rowCountAfterDelay = await this.rows.count(); + // eslint-disable-next-line playwright/prefer-to-have-count -- intentionally non-retrying: verifies count has stabilized + expect(rowCountAfterDelay).toBe(rowCount); + }).toPass({ timeout: DEFAULT_TIMEOUT, intervals: [DEFAULT_INTERVAL] }); + } + + async getRowCount(): Promise { + return await this.rows.count(); + } + + async getSelectableRowIndexes(count: number): Promise { + const rowCount = await this.rows.count(); + const indexes: number[] = []; + + for (let i = 0; i < rowCount; i++) { + const input = this.rows.nth(i).getByTestId("checkbox").locator("input").first(); + if (!(await input.isDisabled())) { + indexes.push(i); + } + + if (indexes.length === count) { + break; + } + } + + return indexes; + } + + async clickSelectAllCheckbox() { + await this.root.getByTestId("select-all-checkbox").locator('input[type="checkbox"]').click(); + } + + async selectRowsByIndex(indexes: number[]) { + for (const index of indexes) { + const row = this.rows.nth(index); + await row.scrollIntoViewIfNeeded(); + await row.getByTestId("checkbox").locator("input").first().click(); + } + } + + async getCellTextByIndex(index: number, cellTestId: string): Promise { + return (await this.rows.nth(index).getByTestId(cellTestId).innerText()).trim(); + } + + async getVisibleCellTexts(cellTestId: string): Promise { + const cells = this.rows.getByTestId(cellTestId); + const count = await cells.count(); + const values: string[] = []; + + for (let i = 0; i < count; i++) { + values.push((await cells.nth(i).innerText()).trim()); + } + + return values; + } + + async selectRowByCellText(cellTestId: string, text: string) { + const rowCount = await this.rows.count(); + + for (let i = 0; i < rowCount; i++) { + const row = this.rows.nth(i); + if ((await row.getByTestId(cellTestId).innerText()).trim() !== text) { + continue; + } + + await row.scrollIntoViewIfNeeded(); + await row.getByTestId("checkbox").locator("input").first().click(); + return; + } + + throw new Error(`Could not find a list row with ${cellTestId}="${text}"`); + } + + async validateCellTextByIndex(index: number, cellTestId: string, expectedText: string) { + await expect(this.rows.nth(index).getByTestId(cellTestId)).toHaveText(expectedText); + } +} diff --git a/client/e2eTests/protoFleet/pages/editPool.ts b/client/e2eTests/protoFleet/pages/editPool.ts new file mode 100644 index 000000000..714356bed --- /dev/null +++ b/client/e2eTests/protoFleet/pages/editPool.ts @@ -0,0 +1,78 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "./base"; + +export class EditPoolPage extends BasePage { + async clickAddPoolButton() { + await this.page.getByTestId("add-pool-button").click(); + } + + async clickAddAnotherPoolButton() { + await this.page.getByTestId("add-another-pool-button").click(); + } + + async clickAddDefaultMiningPool() { + await this.clickIn("Add pool", "default-pool"); + } + + async clickAddBackupPoolOne() { + await this.clickIn("Add pool", "backup-pool-1"); + } + + async clickPoolRowByName(name: string) { + await this.page.getByText(name).click(); + } + + async clickSavePoolChoice() { + await this.clickSaveInModal(); + } + + async clickAddNewPool() { + await this.clickIn("Add new pool", "modal"); + } + + async clickAssignToXMiners(count: number | Promise) { + const minerCount = await Promise.resolve(count); + const buttonText = `Assign to ${minerCount} miner${minerCount === 1 ? "" : "s"}`; + await this.clickButton(buttonText); + } + + async getPoolNameByIndex(index: number): Promise { + const poolRow = this.page.getByTestId(`pool-row-${index}`); + return await poolRow.getByTestId("pool-name").innerText(); + } + + async getPoolUrlByIndex(index: number): Promise { + const poolRow = this.page.getByTestId(`pool-row-${index}`); + return await poolRow.getByTestId("pool-url").innerText(); + } + + async validatePoolCount(count: number) { + const poolRows = this.page.getByTestId(/^pool-row-\d+$/); + await expect(poolRows).toHaveCount(count); + } + + async validatePoolByIndex(index: number, name: string, url: string) { + const poolRow = this.page.getByTestId(`pool-row-${index}`); + await expect(poolRow.getByTestId("pool-name")).toHaveText(name); + await expect(poolRow.getByTestId("pool-url")).toHaveText(url); + } + + async reorderPoolByDragging(fromIndex: number, toIndex: number) { + const sourceHandle = this.page.getByTestId(`pool-row-${fromIndex}`).getByTestId("reorder-handle"); + const targetHandle = this.page.getByTestId(`pool-row-${toIndex}`).getByTestId("reorder-handle"); + await sourceHandle.dragTo(targetHandle, { steps: 20 }); + } + + async removeAllPools() { + const poolRows = this.page.getByTestId(/^pool-row-\d+$/); + const poolCount = await poolRows.count(); + + for (let i = 0; i < poolCount; i++) { + const firstRow = poolRows.first(); + await firstRow.getByRole("button", { name: "Pool actions", exact: true }).click(); + await this.clickButton("Remove"); + await expect(poolRows).toHaveCount(poolCount - 1 - i); + } + await expect(poolRows).toHaveCount(0); + } +} diff --git a/client/e2eTests/protoFleet/pages/groups.ts b/client/e2eTests/protoFleet/pages/groups.ts new file mode 100644 index 000000000..5945f2147 --- /dev/null +++ b/client/e2eTests/protoFleet/pages/groups.ts @@ -0,0 +1,253 @@ +import { expect, type Locator } from "@playwright/test"; +import { DEFAULT_INTERVAL, DEFAULT_TIMEOUT } from "../config/test.config"; +import { BasePage } from "./base"; +import { ModalMinerSelectionList } from "./components/modalMinerSelectionList"; + +const EMPTY_GROUP_PLACEHOLDER = "—"; + +export class GroupsPage extends BasePage { + private readonly modalMinerList = new ModalMinerSelectionList(this.page.getByTestId("modal")); + + private async clickLocator(locator: Locator) { + try { + await locator.click({ timeout: 2000 }); + } catch { + await locator.evaluate((node) => { + (node as HTMLElement).click(); + }); + } + } + + async waitForSavedGroupsListToLoad() { + const rows = this.page.getByTestId("list-row"); + + await expect(this.page.getByRole("button", { name: "Add group" })).toBeVisible(); + await expect(async () => { + const rowCount = await rows.count(); + await new Promise((resolve) => setTimeout(resolve, DEFAULT_INTERVAL)); + const rowCountAfterDelay = await rows.count(); + // eslint-disable-next-line playwright/prefer-to-have-count -- intentionally non-retrying: verifies count has stabilized + expect(rowCountAfterDelay).toBe(rowCount); + }).toPass({ timeout: DEFAULT_TIMEOUT, intervals: [DEFAULT_INTERVAL] }); + } + + private async clickDropdownFilterOption(popover: Locator, optionNames: string[]) { + for (const optionName of optionNames) { + const optionByTestId = popover.getByTestId(`filter-option-${optionName}`).first(); + if (await optionByTestId.isVisible().catch(() => false)) { + await optionByTestId.evaluate((node) => { + node.scrollIntoView({ block: "center", inline: "nearest" }); + }); + await this.clickLocator(optionByTestId); + return; + } + + const optionByText = popover.getByText(optionName, { exact: true }).first(); + if (await optionByText.isVisible().catch(() => false)) { + await optionByText.evaluate((node) => { + node.scrollIntoView({ block: "center", inline: "nearest" }); + }); + await this.clickLocator(optionByText); + return; + } + } + + throw new Error(`Unable to find filter option. Tried: ${optionNames.join(", ")}`); + } + + async clickAddGroupButton() { + await this.clickButton("Add group"); + await this.validateModalIsOpen(); + } + + async closeModal() { + await this.page.getByTestId("modal").getByTestId("header-icon-button").click(); + await this.validateModalIsClosed(); + } + + async openSavedGroup(groupName: string) { + const groupRow = this.getGroupRow(groupName); + await expect(groupRow).toBeVisible(); + + await groupRow.getByLabel("Device set actions").click(); + await this.clickButton("Edit group"); + await this.validateModalIsOpen(); + } + + async inputGroupName(groupName: string) { + await this.page.locator(`//input[@id='group-name']`).fill(groupName); + } + + async clickSelectAllCheckboxInModal() { + await this.modalMinerList.clickSelectAllCheckbox(); + } + + async waitForModalListToLoad() { + await this.modalMinerList.waitForListToLoad(); + } + + async getModalListRowCount(): Promise { + return await this.modalMinerList.getRowCount(); + } + + async selectMinersByIndex(indexes: number[]) { + await this.modalMinerList.selectRowsByIndex(indexes); + } + + async validateMinerGroupsByIndex(index: number, expectedGroups: string) { + const groupCell = this.page.getByTestId("modal").getByTestId("list-row").nth(index).getByTestId("group"); + await expect(groupCell).toHaveText(expectedGroups); + } + + async getModalRowGroupByIndex(index: number): Promise { + const groupText = await this.modalMinerList.getCellTextByIndex(index, "group"); + return groupText === EMPTY_GROUP_PLACEHOLDER ? "" : groupText; + } + + async getModalRowIpAddressByIndex(index: number): Promise { + return await this.modalMinerList.getCellTextByIndex(index, "ipAddress"); + } + + async getUngroupedMinerIps(limit: number): Promise { + const rowCount = await this.getModalListRowCount(); + const minerIps: string[] = []; + + for (let i = 0; i < rowCount && minerIps.length < limit; i++) { + if ((await this.getModalRowGroupByIndex(i)) !== "") { + continue; + } + minerIps.push(await this.getModalRowIpAddressByIndex(i)); + } + + return minerIps; + } + + async selectMinerByIp(ipAddress: string) { + await this.modalMinerList.selectRowByCellText("ipAddress", ipAddress); + } + + async validateMinerGroupsByIp(ipAddress: string, expectedGroups: string) { + const groupCell = this.page + .getByTestId("modal") + .getByTestId("list-row") + .filter({ has: this.page.getByTestId("ipAddress").getByText(ipAddress, { exact: true }) }) + .first() + .getByTestId("group"); + await expect(groupCell).toHaveText(expectedGroups); + } + + async getModalVisibleIpAddresses(): Promise { + return await this.modalMinerList.getVisibleCellTexts("ipAddress"); + } + + async validateOnlyTheseIpsVisibleInModal(expectedIps: string[]) { + const visibleIps = await this.getModalVisibleIpAddresses(); + expect(visibleIps).toHaveLength(expectedIps.length); + const expectedSet = new Set(expectedIps); + for (const ip of visibleIps) { + expect(expectedSet.has(ip)).toBe(true); + } + } + + async filterModalType(type: string) { + await this.clickLocator(this.page.getByTestId("modal").getByTestId("filter-dropdown-Model")); + const popover = this.page.getByTestId("dropdown-filter-popover"); + await expect(popover).toBeVisible(); + await expect(popover).toHaveCSS("opacity", "1"); + await this.clickDropdownFilterOption(popover, [type]); + await this.clickLocator(popover.getByRole("button", { name: "Apply" })); + await expect(popover).toBeHidden(); + } + + async filterModalGroup(groupName: string) { + await this.page.getByTestId("modal").getByTestId("filter-dropdown-Group").click(); + const popover = this.page.getByTestId("dropdown-filter-popover"); + await expect(popover).toBeVisible(); + await expect(popover).toHaveCSS("opacity", "1"); + + const resetButton = popover.getByRole("button", { name: "Reset" }); + await resetButton.click(); + + await popover.getByText(groupName, { exact: true }).click(); + await popover.getByRole("button", { name: "Apply" }).click(); + await expect(popover).toBeHidden(); + } + + async clickDeleteGroupInModal() { + await this.clickIn("Delete group", "modal"); + } + + async clickDeleteConfirm() { + await this.clickButton("Delete"); + } + + async validateErrorMessage(text: string) { + await expect(this.page.getByTestId("error-msg")).toHaveText(text); + } + + async validateSavedGroupVisible(groupName: string) { + await expect(this.getGroupRow(groupName)).toBeVisible(); + } + + async validateSavedGroupNotVisible(groupName: string) { + await expect(this.getGroupRow(groupName)).toBeHidden(); + } + + async validateSavedGroupMinerCount(groupName: string, minerCount: number) { + await expect(this.getGroupRow(groupName).getByTestId("miners")).toHaveText(`${minerCount}`); + } + + async getSavedGroupCount(): Promise { + const rows = this.page.getByTestId("list-row"); + return await rows.count(); + } + + async listSavedGroupNames(): Promise { + await this.waitForSavedGroupsListToLoad(); + + const nameCells = this.page.getByTestId("list-row").getByTestId("name"); + const count = await nameCells.count(); + const names: string[] = []; + for (let i = 0; i < count; i++) { + names.push((await nameCells.nth(i).innerText()).trim()); + } + return names; + } + + async deleteSavedGroupIfVisible(groupName: string) { + const groupRow = this.getGroupRow(groupName); + if (!(await groupRow.isVisible().catch(() => false))) { + return; + } + + await this.openSavedGroup(groupName); + await this.clickDeleteGroupInModal(); + await this.clickDeleteConfirm(); + await this.validateSavedGroupNotVisible(groupName); + } + + private getGroupRow(groupName: string) { + return this.page + .getByTestId("list-row") + .filter({ has: this.page.getByTestId("name").getByText(groupName, { exact: true }) }) + .first(); + } + + async clickGroupActionsButton(groupName: string) { + const groupRow = this.getGroupRow(groupName); + await expect(groupRow).toBeVisible(); + await groupRow.getByLabel("Device set actions").click(); + } + + async clickRebootGroupButton() { + await this.page.getByTestId("reboot-popover-button").click(); + } + + async validateRebootConfirmationModal(minerCount: number) { + await this.validateTitle(`Reboot ${minerCount} miners?`); + } + + async clickRebootConfirm() { + await this.clickButton("Reboot"); + } +} diff --git a/client/e2eTests/protoFleet/pages/home.ts b/client/e2eTests/protoFleet/pages/home.ts new file mode 100644 index 000000000..4083f410d --- /dev/null +++ b/client/e2eTests/protoFleet/pages/home.ts @@ -0,0 +1,122 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "./base"; + +export class HomePage extends BasePage { + private getOverviewIssueCard(title: string) { + return this.page + .locator(`//*[self::a or self::div][contains(@class,'rounded-xl')][.//*[normalize-space(text())='${title}']]`) + .first(); + } + + private getOverviewIssueLink(title: string) { + return this.page.locator(`//a[contains(@class,'rounded-xl')][.//*[normalize-space(text())='${title}']]`); + } + + async validateCompleteSetupTitle() { + await this.validateTitle("Complete setup"); + } + + async validateHomePageOpened() { + await expect(this.page).toHaveURL(/.*\/$/); + } + + async clickAuthenticateMinersButton() { + await this.clickButton("Authenticate"); + } + + async validateAuthenticateMinersModalTitle() { + await this.validateTitleInModal("Authenticate miners"); + } + + async inputMinerAuthUsername(username: string) { + await this.page.locator(`//input[@id='username']`).fill(username); + } + + async inputMinerAuthPassword(password: string) { + await this.page.locator(`//input[@id='password']`).fill(password); + } + + async clickAuthenticateMinersConfirmButton() { + await this.page.getByTestId("modal").getByRole("button", { name: "Authenticate" }).click(); + } + + async validateCompleteSetupTitleNotVisible() { + await this.validateTitleNotVisible("Complete setup"); + } + + async validateAuthenticateMinersButtonNotVisible() { + await expect(this.page.getByRole("button", { name: "Authenticate" })).toBeHidden(); + } + + async clickControlBoardsLink() { + await this.page.getByRole("link", { name: "Control Boards" }).click(); + } + + async clickFansLink() { + await this.page.getByRole("link", { name: "Fans" }).click(); + } + + async clickHashboardsLink() { + await this.page.getByRole("link", { name: "Hashboards" }).click(); + } + + async clickPowerSuppliesLink() { + await this.page.getByRole("link", { name: "Power supplies" }).click(); + } + + async validateOverviewIssueCard(title: string, statusText: string) { + const card = this.getOverviewIssueCard(title); + await expect(card).toBeVisible(); + await expect(card).toContainText(statusText); + } + + async validateOverviewIssueCardIsNotClickable(title: string) { + await expect(this.getOverviewIssueLink(title)).toHaveCount(0); + } + + async getListOfMinersToAuthenticate(): Promise { + return this.page.getByTestId("modal").getByTestId("model").allTextContents(); + } + + async clickShowMinersButton() { + await this.page.getByTestId("modal").getByRole("button", { name: "Show miners" }).click(); + } + + async validateCalloutInModal(text: string) { + await expect(this.page.getByTestId("modal").locator("[data-testid*='callout']").getByText(text)).toBeVisible(); + } + + async validateNoCalloutInModal() { + await expect(this.page.getByTestId("modal").locator("[data-testid*='callout']")).toBeHidden(); + } + + async clickCalloutButton() { + await this.page.getByTestId("modal").locator("[data-testid*='callout']").getByRole("button").click(); + } + + async getMinerRowByModel(model: string) { + return this.page + .getByTestId("modal") + .locator("tr") + .filter({ has: this.page.getByTestId("model").getByText(model) }); + } + + async clickMinerAuthCheckbox(model: string) { + const row = await this.getMinerRowByModel(model); + await row.locator('input[type="checkbox"]').click(); + } + + async inputMinerRowUsername(model: string, username: string) { + const row = await this.getMinerRowByModel(model); + await row.getByTestId("username").locator("input").fill(username); + } + + async inputMinerRowPassword(model: string, password: string) { + const row = await this.getMinerRowByModel(model); + await row.getByTestId("password").locator("input").fill(password); + } + + async validateModalClosed() { + await expect(this.page.getByTestId("modal")).toBeHidden(); + } +} diff --git a/client/e2eTests/protoFleet/pages/miners.ts b/client/e2eTests/protoFleet/pages/miners.ts new file mode 100644 index 000000000..91f663d4f --- /dev/null +++ b/client/e2eTests/protoFleet/pages/miners.ts @@ -0,0 +1,940 @@ +import { expect, type Locator } from "@playwright/test"; +import { DEFAULT_INTERVAL, DEFAULT_TIMEOUT } from "../config/test.config"; +import { PROTO_RIG_DISPLAY_NAME, PROTO_RIG_MODEL } from "../helpers/minerModels"; +import { type IssueIconId } from "../helpers/testDataHelper"; +import { BasePage } from "./base"; + +const PROLONGED_TIMEOUT = DEFAULT_TIMEOUT * 4; + +export class MinersPage extends BasePage { + private async clickDropdownFilterOption(popover: Locator, optionNames: string[]) { + for (const optionName of optionNames) { + const optionByTestId = popover.getByTestId(`filter-option-${optionName}`).first(); + if (await optionByTestId.isVisible().catch(() => false)) { + await optionByTestId.click(); + return; + } + + const optionByText = popover.getByText(optionName, { exact: true }).first(); + if (await optionByText.isVisible().catch(() => false)) { + await optionByText.click(); + return; + } + } + + throw new Error(`Unable to find filter option. Tried: ${optionNames.join(", ")}`); + } + + async validateMinersPageOpened() { + await this.validateTitle("Miners"); + } + + async validateAmountOfMiners(minerCount: number) { + const rows = this.page.getByTestId("list-body").locator("tr"); + await expect(rows).toHaveCount(minerCount); + } + + async validateMinersAdded(minerCount: number = 5) { + const rows = this.page.getByTestId("list-body").locator("tr"); + expect(await rows.count()).toBeGreaterThanOrEqual(minerCount); + } + + private async filterMinersByModel(minerType: string) { + await this.page.getByTestId("filter-dropdown-Model").click(); + const popover = this.page.getByTestId("dropdown-filter-popover"); + await expect(popover).toBeVisible(); + await expect(popover).toHaveCSS("opacity", "1"); + await this.clickDropdownFilterOption(popover, [minerType]); + await popover.getByRole("button", { name: "Apply" }).click(); + await expect(popover).toBeHidden(); + } + + async filterRigMiners() { + await this.filterMinersByModel(PROTO_RIG_MODEL); + await this.waitForAntminersToDisappear(); + } + + async filterAllMinersExceptRig() { + await this.page.getByTestId("filter-dropdown-Model").click(); + const popover = this.page.getByTestId("dropdown-filter-popover"); + await expect(popover).toBeVisible(); + await expect(popover).toHaveCSS("opacity", "1"); + await popover.getByText("Select all", { exact: true }).click(); + await this.clickDropdownFilterOption(popover, [PROTO_RIG_MODEL]); + + await popover.getByRole("button", { name: "Apply" }).click(); + await expect(popover).toBeHidden(); + await this.waitForRigMinersToDisappear(); + } + + async waitForAntminersToDisappear() { + const antminerRows = this.page + .getByTestId("list-body") + .locator("tr") + .filter({ has: this.page.getByTestId("name").getByText("Antminer") }); + await expect(antminerRows).toHaveCount(0); + } + + async waitForRigMinersToDisappear() { + const rigRows = this.page + .getByTestId("list-body") + .locator("tr") + .filter({ has: this.page.getByTestId("name").getByText(PROTO_RIG_DISPLAY_NAME, { exact: true }) }); + await expect(rigRows).toHaveCount(0); + } + + async getMinerRowByIp(ipAddress: string): Promise { + return this.page.locator(`//tr[child::*[@data-testid="ipAddress" and descendant::text()='${ipAddress}']]`); + } + + async validateMinerInList(ipAddress: string) { + await expect(await this.getMinerRowByIp(ipAddress)).toBeVisible(); + } + + async validateMinerValue(minerName: string, columnTestId: string, expectedValue: string) { + const minerRow = await this.getMinerRowByIp(minerName); + const columnLocator = minerRow.locator(`//td[@data-testid='${columnTestId}']`); + await expect(columnLocator).toHaveText(expectedValue); + } + + async validateMinerIcon(minerIp: string, columnTestId: string, iconId: IssueIconId) { + const minerRow = await this.getMinerRowByIp(minerIp); + const columnLocator = minerRow.locator(`//td[@data-testid='${columnTestId}']`); + await expect(columnLocator.getByTestId(iconId)).toBeVisible(); + } + + async clickMinerThreeDotsButton(ipAddress: string) { + const minerRow = await this.getMinerRowByIp(ipAddress); + await minerRow.getByTestId(`single-miner-actions-menu-button`).click(); + } + async clickMinerCheckbox(ipAddress: string) { + const minerRow = await this.getMinerRowByIp(ipAddress); + await minerRow.locator(`//input[@type='checkbox']`).click(); + } + + async clickMinerCheckboxByIndex(index: number) { + const rows = this.page.getByTestId("list-body").locator("tr"); + const row = rows.nth(index); + await row.scrollIntoViewIfNeeded(); + await row.locator('input[type="checkbox"]').first().click(); + } + + async waitForMinersTitle() { + await this.validateTitle("Miners"); + } + + async clickSelectAllCheckbox() { + await this.page.getByTestId("list-header").locator('input[type="checkbox"]').click(); + } + + async uncheckSelectAllCheckbox() { + const checkbox = this.page.getByTestId("list-header").locator('input[type="checkbox"]'); + if (await checkbox.isChecked()) { + await checkbox.click(); + } + } + + async clickActionsMenuButton() { + await this.page.getByTestId("actions-menu-button").click(); + } + + async validateActionBarMinerCount(expectedCount: number) { + await expect(this.page.getByTestId("action-bar")).toBeVisible(); + if (expectedCount === 1) { + await expect(this.page.getByTestId("action-bar").getByText("1 miner selected")).toBeVisible(); + } else { + await expect(this.page.getByTestId("action-bar").getByText(`${expectedCount} miners selected`)).toBeVisible(); + } + } + + async clickRebootButton() { + await this.page.getByTestId("reboot-popover-button").click(); + } + + async clickRebootConfirm() { + await this.page.getByTestId("reboot-confirm-button").click(); + } + + async clickWakeUpButton() { + await this.page.getByTestId("wake-up-popover-button").click(); + } + + async clickWakeUpConfirm() { + await this.page.getByTestId("wake-up-confirm-button").click(); + } + + async clickShutdownButton() { + await this.page.getByTestId("shutdown-popover-button").click(); + } + + async clickShutdownConfirm() { + await this.page.getByTestId("shutdown-confirm-button").click(); + } + + async clickManagePowerButton() { + await this.page.getByTestId("manage-power-popover-button").click(); + } + + async clickMaxPowerOption() { + await this.page.getByTestId("power-option-maximize").locator("input").click(); + } + + async clickReducePowerOption() { + await this.page.getByTestId("power-option-reduce").locator("input").click(); + } + + async clickManagePowerConfirm() { + await this.clickIn("Confirm", "modal"); + } + + async clickEditMiningPoolButton() { + await this.page.getByTestId("mining-pool-popover-button").click(); + } + + async clickUpdateFirmwareButton() { + await this.page.getByTestId("firmware-update-popover-button").click(); + } + + async validateFirmwareUpdateModalOpened() { + await this.validateTitleInModal("Add firmware payload"); + } + + async selectExistingFirmwareFile(fileName: string) { + await this.page.getByRole("radio").filter({ hasText: fileName }).click(); + } + + async clickContinueInFirmwareUpdateModal() { + await this.clickIn("Continue", "modal"); + } + + async clickCoolingModeButton() { + await this.page.getByTestId("cooling-mode-popover-button").click(); + } + + async validateAirCooledOptionSelected() { + await expect(this.page.getByTestId("cooling-option-air").locator("input")).toBeChecked(); + } + + async clickAirCooledOption() { + await this.page.getByTestId("cooling-option-air").locator("input").click(); + } + + async clickImmersionCooledOption() { + await this.page.getByTestId("cooling-option-immersion").locator("input").click(); + } + + async clickUpdateCoolingModeConfirm() { + await this.page.getByRole("button", { name: "Update cooling mode" }).click(); + } + + async clickRenameButton() { + await this.page.getByTestId("rename-popover-button").click(); + } + + async clickAddToGroupButton() { + await this.page.getByTestId("add-to-group-popover-button").click(); + } + + async inputNewGroupName(groupName: string) { + await this.page.locator("#new-group-name").fill(groupName); + } + + async validateMinerGroupName(ipAddress: string, expectedGroupName: string) { + const minerRow = await this.getMinerRowByIp(ipAddress); + await expect(minerRow.getByTestId("groups")).toContainText(expectedGroupName); + } + + async validateBulkRenamePageOpened() { + await this.validateTitle("Rename miners"); + } + + private bulkRenamePreviewContainer(): Locator { + return this.isMobile + ? this.page.getByTestId("bulk-rename-mobile-preview") + : this.page.getByTestId("bulk-rename-desktop-preview"); + } + + async validateBulkRenamePreviewContainsName(name: string) { + const container = this.bulkRenamePreviewContainer(); + await expect(container).toContainText(name); + } + + async getBulkRenamePreviewName(): Promise { + const container = this.bulkRenamePreviewContainer(); + await expect(container).toBeVisible(); + + const activeNewName = container.getByTestId("active-new-name").first(); + await expect(activeNewName).toBeVisible(); + return (await activeNewName.innerText()).trim(); + } + + async validateBulkRenamePreviewUnchangedPlaceholder() { + const container = this.bulkRenamePreviewContainer(); + await expect(container).toBeVisible(); + await expect(container.getByTestId("active-new-name")).toHaveCount(0); + await expect(container).toContainText("—"); + } + + async waitForBulkRenamePreviewName(expectedName: string) { + await expect + .poll(async () => await this.getBulkRenamePreviewName(), { + timeout: DEFAULT_TIMEOUT, + }) + .toBe(expectedName); + } + + async validateBulkRenamePreviewState(expectedName: string, currentName: string) { + if (expectedName === currentName) { + await this.validateBulkRenamePreviewUnchangedPlaceholder(); + return; + } + + await this.waitForBulkRenamePreviewName(expectedName); + } + + async clickBulkRenamePropertyToggle(propertyId: string) { + await this.page.getByTestId(`bulk-rename-row-${propertyId}`).locator('label:has(input[type="checkbox"])').click(); + } + + async getBulkRenamePropertyOrder(): Promise { + const rows = this.page.locator('[data-testid^="bulk-rename-row-"]'); + const count = await rows.count(); + const propertyIds: string[] = []; + + for (let i = 0; i < count; i++) { + const testId = await rows.nth(i).getAttribute("data-testid"); + if (testId) { + propertyIds.push(testId.replace("bulk-rename-row-", "")); + } + } + + return propertyIds; + } + + async setBulkRenamePropertyOrder(propertyIds: readonly string[]) { + const didPersist = await this.page.evaluate((orderedPropertyIds) => { + const storageKey = "proto-ui-preferences"; + const raw = window.localStorage.getItem(storageKey); + if (!raw) { + return false; + } + + const persisted = JSON.parse(raw); + const preferences = persisted?.state?.ui?.bulkRenamePreferences; + const properties = preferences?.properties; + if (!Array.isArray(properties)) { + return false; + } + + const propertyById = new Map(properties.map((property: { id: string }) => [property.id, property])); + const reorderedProperties = orderedPropertyIds + .map((propertyId) => propertyById.get(propertyId)) + .filter((property): property is { id: string } => Boolean(property)); + const remainingProperties = properties.filter( + (property: { id: string }) => !orderedPropertyIds.includes(property.id), + ); + + persisted.state.ui.bulkRenamePreferences = { + ...preferences, + properties: [...reorderedProperties, ...remainingProperties], + }; + + window.localStorage.setItem(storageKey, JSON.stringify(persisted)); + return true; + }, propertyIds); + + expect(didPersist, "Expected bulk rename preferences to be persisted in localStorage").toBe(true); + + await this.reloadPage(); + await this.waitForMinersTitle(); + await this.waitForMinersListToLoad(); + } + + async toggleBulkRenameProperty(propertyId: string, enabled: boolean) { + const row = this.page.getByTestId(`bulk-rename-row-${propertyId}`); + const checkbox = row.locator('label:has(input[type="checkbox"]) input[type="checkbox"]'); + await expect(checkbox).toHaveCount(1); + + const isChecked = await checkbox.isChecked(); + if (isChecked !== enabled) { + await this.clickBulkRenamePropertyToggle(propertyId); + if (enabled) { + await expect(checkbox).toBeChecked(); + } else { + await expect(checkbox).not.toBeChecked(); + } + } + } + + async clickBulkRenamePropertyOptions(propertyId: string) { + await this.page.getByTestId(`bulk-rename-options-${propertyId}`).click(); + } + + async dismissRenameOptionsModal() { + const modal = this.page.getByTestId("modal"); + + if (this.isMobile) { + const cancelButton = modal.getByRole("button", { name: "Cancel", exact: true }); + await expect(cancelButton).toBeVisible(); + await cancelButton.click(); + await this.validateModalIsClosed(); + return; + } + + const headerDismiss = modal.getByTestId("header-icon-button"); + const headerVisible = await headerDismiss.isVisible().catch(() => false); + if (headerVisible) { + await headerDismiss.click(); + await this.validateModalIsClosed(); + return; + } + + const cancelButton = modal.getByRole("button", { name: "Cancel", exact: true }); + await expect(cancelButton).toBeVisible(); + await cancelButton.click(); + await this.validateModalIsClosed(); + } + + async fillCustomPropertyPrefix(prefix: string) { + await this.page.getByTestId("custom-property-prefix-input").fill(prefix); + } + + async fillCustomPropertySuffix(suffix: string) { + await this.page.getByTestId("custom-property-suffix-input").fill(suffix); + } + + async fillCustomPropertyCounterStart(value: string | number) { + await this.page.getByTestId("custom-property-counter-start-input").fill(String(value)); + } + + async clickCustomPropertyCounterScale(counterScale: number) { + const counterScaleGroup = this.page.getByRole("radiogroup", { name: "Counter scale" }); + await expect(counterScaleGroup).toBeVisible(); + + const option = counterScaleGroup.getByTestId(`custom-property-counter-scale-option-${counterScale}`); + await option.click(); + await expect(option.locator('input[type="radio"]')).toBeChecked(); + } + + async clickCustomPropertyTypeButton() { + await this.page.getByTestId("custom-property-type-button").click(); + } + + async selectCustomPropertyType(typeId: string) { + await this.clickCustomPropertyTypeButton(); + await this.page.getByTestId(`custom-property-type-option-${typeId}`).click(); + } + + async fillCustomPropertyStringValue(value: string) { + await this.page.getByTestId("custom-property-string-input").fill(value); + } + + async validateCustomPropertyPreviewText(expectedText: string) { + await expect( + this.page.getByTestId("custom-property-preview"), + `Custom property preview should show "${expectedText}"`, + ).toHaveText(expectedText); + } + + async validateCustomPropertySaveDisabled() { + const desktopSave = this.page.getByTestId("custom-property-options-save-button"); + const mobileSave = this.page.getByTestId("custom-property-options-save-button-mobile"); + + const desktopVisible = await desktopSave.isVisible().catch(() => false); + const mobileVisible = await mobileSave.isVisible().catch(() => false); + + expect(desktopVisible || mobileVisible, "Expected at least one Save button to be visible").toBe(true); + + if (desktopVisible) { + await expect(desktopSave, "Desktop Save button should be disabled when counter start is empty").toBeDisabled(); + } + + if (mobileVisible) { + await expect(mobileSave, "Mobile Save button should be disabled when counter start is empty").toBeDisabled(); + } + } + + async clickFixedValueCharacterCountOption(option: number | "all") { + const optionId = typeof option === "number" ? String(option) : option; + const label = this.page.getByTestId(`fixed-value-character-count-option-${optionId}`); + await label.click(); + await expect(label.locator('input[type="radio"]')).toBeChecked(); + } + + async clickFixedValueStringSectionOption(section: "first" | "last") { + const label = this.page.getByTestId(`fixed-value-string-section-option-${section}`); + await label.click(); + await expect(label.locator('input[type="radio"]')).toBeChecked(); + } + + async validateFixedValuePreviewText(expectedText: string) { + if (expectedText === "") { + await expect(this.page.getByTestId("modal")).toContainText("—"); + return; + } + + await expect( + this.page.getByTestId("fixed-value-preview-highlighted"), + `Fixed value preview should show "${expectedText}"`, + ).toHaveText(expectedText); + } + + async getFixedValuePreviewText(): Promise { + const preview = this.page.getByTestId("fixed-value-preview-highlighted"); + const hasPreview = await preview.isVisible().catch(() => false); + if (hasPreview) { + return (await preview.innerText()).trim(); + } + + await expect(this.page.getByTestId("modal")).toContainText("—"); + return ""; + } + + async setCustomBulkRenameCounterScale(counterScale: number) { + await this.clickBulkRenamePropertyOptions("custom"); + + const counterStartInput = this.page.getByTestId("custom-property-counter-start-input"); + const isCounterStartVisible = await counterStartInput.isVisible(); + if (isCounterStartVisible) { + const currentValue = (await counterStartInput.inputValue()).trim(); + if (currentValue === "") { + await counterStartInput.fill("1"); + } + } + + const counterScaleGroup = this.page.getByRole("radiogroup", { name: "Counter scale" }); + await expect(counterScaleGroup).toBeVisible(); + const option = counterScaleGroup.getByTestId(`custom-property-counter-scale-option-${counterScale}`); + await option.click(); + await expect(option.locator('input[type="radio"]')).toBeChecked(); + + await this.clickIn("Save", "modal"); + await this.validateModalIsClosed(); + } + + async clickBulkRenameSave() { + await this.page.getByTestId("bulk-rename-save-button").filter({ visible: true }).click(); + } + + async selectBulkRenameSeparator(separatorId: string) { + const separator = this.page.getByTestId(`bulk-rename-separator-${separatorId}`); + const radio = separator.locator('input[type="radio"]'); + + if (await radio.isChecked()) { + return; + } + + await separator.locator("xpath=ancestor::label").click(); + await expect(radio).toBeChecked(); + } + + async confirmBulkRenameWarningsIfPresent() { + const duplicateNamesDialog = this.page.getByTestId("bulk-rename-duplicate-names-dialog"); + try { + await duplicateNamesDialog.waitFor({ state: "visible", timeout: DEFAULT_INTERVAL }); + await duplicateNamesDialog.getByRole("button", { name: "Yes, continue" }).click(); + } catch { + // Dialog not present, continue + } + + const noChangesDialog = this.page.getByTestId("bulk-rename-no-changes-dialog"); + try { + await noChangesDialog.waitFor({ state: "visible", timeout: DEFAULT_INTERVAL }); + await noChangesDialog.getByRole("button", { name: "Yes, continue" }).click(); + } catch { + // Dialog not present, continue + } + } + + async fillRenameInput(name: string) { + const input = this.page.getByTestId("rename-miner-input"); + await input.fill(name); + } + + async clickRenameSave() { + await this.clickSaveInModal(); + } + + async validateMinerName(ipAddress: string, expectedName: string) { + const minerRow = await this.getMinerRowByIp(ipAddress); + await expect(minerRow.getByTestId("name")).toContainText(expectedName); + } + + async getMinerNameByIndex(index: number): Promise { + const rows = this.page.getByTestId("list-body").locator("tr"); + const row = rows.nth(index); + await row.scrollIntoViewIfNeeded(); + return await row.getByTestId("name").innerText(); + } + + async getMinerNames(): Promise { + const nameElements = this.page.getByTestId("list-body").locator("tr").getByTestId("name"); + const names = await nameElements.allInnerTexts(); + return names.map((name) => name.trim()); + } + + async clickUnpairButton() { + await this.page.getByTestId("unpair-popover-button").click(); + } + + async clickUnpairConfirm() { + await this.page.getByTestId("unpair-confirm-button").click(); + } + + async validateUpdateInProgress() { + await expect(this.page.getByText(/Update in progress|updates in progress/)).toBeVisible(); + } + + async validateUpdateCompleted() { + await expect(this.page.getByText(/Update in progress|updates in progress/)).toBeHidden(); + } + + async waitForMinersListToLoad() { + const rows = this.page.getByTestId("list-body").locator("tr"); + await expect(rows).not.toHaveCount(0); + await expect(async () => { + const rowCount = await rows.count(); + await new Promise((resolve) => setTimeout(resolve, DEFAULT_INTERVAL)); + const rowCountAfterDelay = await rows.count(); + // eslint-disable-next-line playwright/prefer-to-have-count -- intentionally non-retrying: verifies count has stabilized + expect(rowCountAfterDelay).toBe(rowCount); + }).toPass({ timeout: DEFAULT_TIMEOUT, intervals: [DEFAULT_INTERVAL] }); + } + + async validateAllMinersStatus(status: string, expected: boolean = true) { + await this.waitForColumnValuesToLoad("status"); + // To avoid miner actions hiding some valuable data in screenshots + await this.uncheckSelectAllCheckbox(); + const rows = this.page.getByTestId("list-body").locator("tr"); + const rowCount = await rows.count(); + // Start from last row to avoid extremely long tests due to lazy loading + for (let i = rowCount - 1; i >= 0; i--) { + await rows.nth(i).scrollIntoViewIfNeeded(); + const statusLocator = rows.nth(i).locator(`//td[@data-testid='status']`); + if (expected) { + await expect(statusLocator).toContainText(status, { + timeout: PROLONGED_TIMEOUT, + }); + } else { + await expect(statusLocator).not.toContainText(status, { + timeout: PROLONGED_TIMEOUT, + }); + } + } + } + + async validateNoMinerWithStatus(status: string) { + await this.validateAllMinersStatus(status, false); + } + + async validateAllMinersStatusSettled(status: string) { + await this.waitForColumnValuesToLoad("status"); + // To avoid miner actions hiding some valuable data in screenshots + await this.uncheckSelectAllCheckbox(); + const rows = this.page.getByTestId("list-body").locator("tr"); + const rowCount = await rows.count(); + // Start from last row to avoid extremely long tests due to lazy loading + for (let i = rowCount - 1; i >= 0; i--) { + await rows.nth(i).scrollIntoViewIfNeeded(); + const statusCell = rows.nth(i).locator(`//td[@data-testid='status']`); + const statusIndicator = statusCell.getByTestId("miner-status-indicator"); + + await expect(statusCell).toContainText(status, { + timeout: PROLONGED_TIMEOUT, + }); + await expect(statusIndicator).toHaveAttribute("data-status", /^(?!pending$).+/, { + timeout: PROLONGED_TIMEOUT, + }); + } + } + + async getMinerStatus(ipAddress: string): Promise { + const minerRow = await this.getMinerRowByIp(ipAddress); + return await minerRow.locator(`//td[@data-testid='status']`).innerText(); + } + + async getVisibleMinerStatuses(): Promise> { + await this.waitForColumnValuesToLoad("status"); + const rows = this.page.getByTestId("list-body").locator("tr"); + const rowCount = await rows.count(); + const visibleMinerStatuses: Array<{ ipAddress: string; status: string }> = []; + + for (let i = 0; i < rowCount; i++) { + const row = rows.nth(i); + await row.scrollIntoViewIfNeeded(); + visibleMinerStatuses.push({ + ipAddress: (await row.getByTestId("ipAddress").innerText()).trim(), + status: (await row.getByTestId("status").innerText()).trim(), + }); + } + + return visibleMinerStatuses; + } + + async validateMinerStatus(ipAddress: string, expectedStatus: string) { + await expect(async () => { + try { + const minerRow = await this.getMinerRowByIp(ipAddress); + const statusCell = minerRow.locator(`//td[@data-testid='status']`); + + await expect(statusCell).toHaveText(expectedStatus, { timeout: DEFAULT_INTERVAL }); + } catch (error) { + await this.reloadPage(); + const minerRow = await this.getMinerRowByIp(ipAddress); + const statusCell = minerRow.locator(`//td[@data-testid='status']`); + + await expect(statusCell).toBeVisible(); + throw error; + } + }).toPass({ timeout: PROLONGED_TIMEOUT }); + } + + async validateMinerStatusSettled(ipAddress: string, expectedStatus: string, timeoutMs: number = PROLONGED_TIMEOUT) { + await expect(async () => { + try { + const minerRow = await this.getMinerRowByIp(ipAddress); + const statusCell = minerRow.locator(`//td[@data-testid='status']`); + const statusIndicator = statusCell.getByTestId("miner-status-indicator"); + + await expect(statusCell).toHaveText(expectedStatus, { timeout: DEFAULT_INTERVAL }); + await expect(statusIndicator).toHaveAttribute("data-status", /^(?!pending$).+/, { + timeout: DEFAULT_INTERVAL, + }); + } catch (error) { + await this.reloadPage(); + const minerRow = await this.getMinerRowByIp(ipAddress); + const statusCell = minerRow.locator(`//td[@data-testid='status']`); + + await expect(statusCell).toBeVisible(); + throw error; + } + }).toPass({ timeout: timeoutMs }); + } + + async validateAllMinersIssues(issue: string, expected: boolean = true) { + await expect(async () => { + try { + // To make sure all miners are loaded and we are not missing any issues due to lazy loading + await this.waitForColumnValuesToLoad("status"); + // To avoid miner actions hiding some valuable data in screenshots + await this.uncheckSelectAllCheckbox(); + const rows = this.page.getByTestId("list-body").locator("tr"); + const rowCount = await rows.count(); + for (let i = rowCount - 1; i >= 0; i--) { + await rows.nth(i).scrollIntoViewIfNeeded(); + const issuesLocator = rows.nth(i).locator(`//td[@data-testid='issues']`); + + if (expected) { + await expect(issuesLocator).toContainText(issue, { + timeout: DEFAULT_INTERVAL, + }); + } else { + await expect(issuesLocator).not.toContainText(issue, { + timeout: DEFAULT_INTERVAL, + }); + } + } + } catch (error) { + await this.reloadPage(); + throw error; + } + }).toPass({ timeout: PROLONGED_TIMEOUT }); + } + + async validateNoMinerWithIssue(issue: string) { + await this.validateAllMinersIssues(issue, false); + } + + private async waitForColumnValuesToLoad(columnTestId: string) { + const rows = this.page.getByTestId("list-body").locator("tr"); + const rowCount = await rows.count(); + // Start from last row to avoid extremely long tests due to lazy loading + for (let i = rowCount - 1; i >= 0; i--) { + await rows.nth(i).scrollIntoViewIfNeeded(); + await expect(async () => { + const locator = rows.nth(i).locator(`//td[@data-testid='${columnTestId}']`); + await expect(locator).not.toHaveText("", { timeout: 5000 }); + await expect(locator).not.toHaveText("N/A", { timeout: 5000 }); + }).toPass({ timeout: DEFAULT_TIMEOUT, intervals: [DEFAULT_INTERVAL] }); + } + } + + async waitForTemperaturesToLoad() { + await this.waitForColumnValuesToLoad("temperature"); + } + + private async validateTemperatureUnit(expectedUnit: string) { + await this.waitForTemperaturesToLoad(); + const rows = this.page.getByTestId("list-body").locator("tr"); + const rowCount = await rows.count(); + for (let i = 0; i < rowCount; i++) { + const temperatureLocator = rows.nth(i).locator(`//td[@data-testid='temperature']`); + await temperatureLocator.scrollIntoViewIfNeeded(); + + // Get temperature text — format is "65.2 °F" or "65.2 °C" + const temperatureText = await temperatureLocator.innerText(); + const parts = temperatureText.split(" "); + expect(parts, `Expected temperature text to have value and unit, but got: "${temperatureText}"`).toHaveLength(2); + + // Validate unit - °C/°F + const unit = parts[1]; + expect(unit).toBe(expectedUnit); + + // Validate temperature value + const value = parseFloat(parts[0]); + if (expectedUnit === "°F") { + expect(value).toBeGreaterThanOrEqual(70.0); + } else { + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThanOrEqual(100.0); + } + } + } + + async validateTemperatureUnitFahrenheit() { + await this.validateTemperatureUnit("°F"); + } + + async validateTemperatureUnitCelsius() { + await this.validateTemperatureUnit("°C"); + } + + async validateActiveFilter(filterLabel: string) { + const activeFilterButton = this.page.locator(`[data-testid*="active-filter-"]`, { hasText: filterLabel }); + await expect(activeFilterButton).toBeVisible(); + } + + async validateActiveFilterNotVisible(filterLabel: string) { + const activeFilterButton = this.page.locator(`[data-testid*="active-filter-"]`, { hasText: filterLabel }); + await expect(activeFilterButton).toHaveCount(0); + } + + async clickClearAllFilters() { + await this.page.getByRole("button", { name: "Clear all filters", exact: true }).click(); + } + + async validateNoResultsEmptyState() { + await this.page.getByText("No results", { exact: true }).waitFor(); + await expect(this.page.getByText("No results", { exact: true })).toBeVisible(); + await expect(this.page.getByText("Try adjusting or clearing your filters.", { exact: true })).toBeVisible(); + await expect(this.page.getByRole("button", { name: "Clear all filters", exact: true })).toBeVisible(); + } + + async getMinersCount(): Promise { + const rows = this.page.getByTestId("list-body").locator("tr"); + return await rows.count(); + } + + async hasAnyMinerWithStatus(status: string): Promise { + await this.waitForColumnValuesToLoad("status"); + const rows = this.page.getByTestId("list-body").locator("tr"); + const rowCount = await rows.count(); + + for (let i = 0; i < rowCount; i++) { + const statusText = (await rows.nth(i).getByTestId("status").innerText()).trim(); + if (statusText === status) { + return true; + } + } + + return false; + } + + async getMinerIpAddressByIndex(index: number): Promise { + const rows = this.page.getByTestId("list-body").locator("tr"); + const row = rows.nth(index); + return await row.getByTestId("ipAddress").innerText(); + } + + async getMinerIpAddressByStatus(status: string): Promise { + await this.waitForColumnValuesToLoad("status"); + const rows = this.page.getByTestId("list-body").locator("tr"); + const rowCount = await rows.count(); + const visibleStatuses: string[] = []; + + for (let i = 0; i < rowCount; i++) { + const row = rows.nth(i); + const statusText = (await row.getByTestId("status").innerText()).trim(); + if (statusText) { + visibleStatuses.push(statusText); + } + + if (statusText === status) { + return await row.getByTestId("ipAddress").innerText(); + } + } + + throw new Error( + `No visible miner with status "${status}". Visible statuses: ${visibleStatuses.join(", ") || "none"}`, + ); + } + + async getAuthenticatedMinerIpAddressByIndex(index: number): Promise { + // Filter out rows where the checkbox input is disabled (unauthenticated miners) + const allRows = this.page.getByTestId("list-body").locator("tr"); + const authenticatedRows = allRows.filter({ + has: this.page.locator('input[type="checkbox"]:not([disabled])'), + }); + + const authenticatedCount = await authenticatedRows.count(); + if (authenticatedCount <= index) { + throw new Error(`Only ${authenticatedCount} authenticated miners available, cannot get index ${index}`); + } + + const row = authenticatedRows.nth(index); + return await row.getByTestId("ipAddress").innerText(); + } + + async validateMinerNotPresent(ipAddress: string) { + const minerRow = this.page.getByTestId(`ipAddress`).getByText(ipAddress, { exact: true }); + await expect(minerRow).toBeHidden(); + } + + async clickAddMinersButton() { + await this.clickButton("Add miners"); + } + + async clickGetStarted() { + await this.clickButton("Get started"); + } + + async clickMinerElementByTestId(ipAddress: string, testId: string) { + const minerRow = await this.getMinerRowByIp(ipAddress); + await minerRow.getByTestId(testId).click(); + } + + /** + * Click a miner cell's interactive element and wait for the status modal to open. + * Targets the button inside the cell (not the td itself) to avoid clicking + * empty cell padding. Retries if the click doesn't open the modal. + */ + async clickMinerElementAndExpectModal(ipAddress: string, testId: string, minerName: string) { + const modalTitle = this.page.locator( + `//*[@data-testid='modal']//*[contains(@class,'heading')][text()='${minerName} status']`, + ); + await expect(async () => { + const minerRow = await this.getMinerRowByIp(ipAddress); + const cell = minerRow.getByTestId(testId); + // Click the button inside the cell if one exists, otherwise the cell itself + const button = cell.locator("button").first(); + const target = (await button.isVisible().catch(() => false)) ? button : cell; + await target.click(); + await expect(modalTitle).toBeVisible({ timeout: 3000 }); + }).toPass({ timeout: DEFAULT_TIMEOUT }); + } + + async validateMinerIssuesModalOpened(minerName: string) { + await this.validateTitleInModal(`${minerName} status`); + } + + async validateErrorInModal(errorText: string, iconId: IssueIconId) { + const modal = this.page.locator('[role="dialog"], [data-testid*="modal"]'); + await expect(modal.getByText(errorText)).toBeVisible(); + await expect(modal.getByTestId(iconId)).toBeVisible(); + await expect(modal.getByText("Reported on 01/01/2026 at ").first()).toBeVisible(); + } + + async clickCloseStatusModal() { + await this.clickIn("Done", "modal"); + } +} diff --git a/client/e2eTests/protoFleet/pages/newPoolModal.ts b/client/e2eTests/protoFleet/pages/newPoolModal.ts new file mode 100644 index 000000000..e7b461014 --- /dev/null +++ b/client/e2eTests/protoFleet/pages/newPoolModal.ts @@ -0,0 +1,44 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "./base"; + +export class NewPoolModalPage extends BasePage { + async validatePoolModalOpened() { + await expect(this.page.getByTestId("modal").getByText(`Default mining pool`).first()).toBeVisible(); + } + + async inputPoolName(name: string) { + await this.page.getByTestId(`pool-name-0-input`).fill(name); + } + + async inputPoolUrl(url: string) { + await this.page.getByTestId(`url-0-input`).fill(url); + } + + async inputPoolUsername(username: string) { + await this.page.getByTestId(`username-0-input`).fill(username); + } + + async clickTestConnection() { + await this.page.locator(`//button//*[text()='Test connection']`).click(); + } + + async validateConnectionFailed() { + await expect( + this.page.locator(`//div[@data-testid='pool-not-connected-callout' and not(contains(@class,'hidden'))]`), + ).toBeVisible(); + } + + async validateEmptyPoolUrlError() { + await this.validateTextIsVisible("A Pool URL is required to connect to this pool."); + } + + async validateConnectionSuccessful() { + await expect( + this.page.locator(`//div[@data-testid='pool-connected-callout' and not(contains(@class,'hidden'))]`), + ).toBeVisible(); + } + + async clickSaveNewPool() { + await this.page.getByTestId("pool-save-button").click(); + } +} diff --git a/client/e2eTests/protoFleet/pages/racks.ts b/client/e2eTests/protoFleet/pages/racks.ts new file mode 100644 index 000000000..612082f96 --- /dev/null +++ b/client/e2eTests/protoFleet/pages/racks.ts @@ -0,0 +1,532 @@ +import { expect, type Locator } from "@playwright/test"; +import { DEFAULT_INTERVAL, DEFAULT_TIMEOUT } from "../config/test.config"; +import { BasePage } from "./base"; +import { ModalMinerSelectionList } from "./components/modalMinerSelectionList"; + +export interface RackSelectorMiner { + ipAddress: string; + sortName: string; +} + +export class RacksPage extends BasePage { + private readonly modalMinerList = new ModalMinerSelectionList(this.page.getByTestId("modal")); + + async validateRacksPageOpened() { + await this.validateTitle("Racks"); + } + + async clickAddRackButton() { + await this.clickButton("Add rack"); + await this.validateTitleInModal("Rack settings"); + } + + async inputZone(zone: string) { + await this.page.locator("#rack-zone").fill(zone); + } + + async inputRackLabel(label: string) { + await this.page.locator("#rack-label").fill(label); + } + + async getGeneratedRackLabel(): Promise { + return await this.page.locator("#rack-label").inputValue(); + } + + async enableCustomRackLayout() { + const columnsInput = this.page.locator("#rack-columns"); + if (!(await columnsInput.isDisabled())) { + return; + } + + await this.selectOption("rack-type-select", "New Layout"); + } + + async inputColumns(columns: number | string) { + await this.page.locator("#rack-columns").fill(String(columns)); + } + + async inputRows(rows: number | string) { + await this.page.locator("#rack-rows").fill(String(rows)); + } + + async getOrderIndexValue(): Promise { + const text = await this.page.getByTestId("order-index-select").innerText(); + return text + .replace(/\s+/g, " ") + .replace(/^Order index\s*/i, "") + .trim(); + } + + async clickContinueFromRackSettings() { + await this.clickIn("Continue", "modal"); + } + + async validateRackSettingsFieldError( + fieldId: "rack-zone" | "rack-label" | "rack-columns" | "rack-rows", + message: string, + ) { + await expect(this.page.locator(`#${fieldId}-error`)).toHaveText(message); + } + + async validateRackConfiguration(columns: number, rows: number, orderIndexValue: string) { + await expect(this.page.getByText(`${columns}x${rows}, ${orderIndexValue}`, { exact: true })).toBeVisible(); + } + + async validateAssignedMinersCount(assigned: number, total: number) { + await expect(this.page.getByText(`${assigned}/${total} assigned`, { exact: true })).toBeVisible(); + } + + async clickAddMiners() { + await this.clickButton("Add miners"); + await this.validateTitleInModal("Select miners"); + } + + async clickManageMiners() { + const overflowTrigger = this.page.getByTestId("overflow-menu-trigger"); + if (this.isMobile && (await overflowTrigger.isVisible().catch(() => false))) { + await overflowTrigger.click(); + } + + await this.clickButton("Manage Miners"); + await this.validateTitleInModal("Select miners"); + } + + async waitForMinerSelectorListToLoad() { + await this.modalMinerList.waitForListToLoad(); + } + + async getAllVisibleMinersFromSelector(): Promise { + const rowCount = await this.modalMinerList.getRowCount(); + const miners: RackSelectorMiner[] = []; + + for (let i = 0; i < rowCount; i++) { + miners.push({ + ipAddress: await this.modalMinerList.getCellTextByIndex(i, "ipAddress"), + sortName: await this.modalMinerList.getCellTextByIndex(i, "name"), + }); + } + + return miners; + } + + async getMinersFromSelector(indexes: number[]): Promise { + const miners: RackSelectorMiner[] = []; + + for (const index of indexes) { + miners.push({ + ipAddress: await this.modalMinerList.getCellTextByIndex(index, "ipAddress"), + sortName: await this.modalMinerList.getCellTextByIndex(index, "name"), + }); + } + + return miners; + } + + async getSelectableMinerIndexes(count: number): Promise { + const indexes = await this.modalMinerList.getSelectableRowIndexes(count); + expect(indexes).toHaveLength(count); + return indexes; + } + + async selectMinersInSelectorByIndex(indexes: number[]) { + await this.modalMinerList.selectRowsByIndex(indexes); + } + + async clickSelectAllMinersInSelector() { + await this.modalMinerList.clickSelectAllCheckbox(); + } + + async toggleMinerInSelectorByIpAddress(ipAddress: string) { + await this.modalMinerList.selectRowByCellText("ipAddress", ipAddress); + } + + async clickContinueInMinerSelector() { + await this.clickIn("Continue", "modal"); + } + + async validateMinerSelectorOverflowError(selectedCount: number, maxSlots: number) { + await this.validateTextInModal( + `Cannot add ${selectedCount} miners with only ${maxSlots} available slots. Deselect some miners or update your rack settings.`, + ); + } + + async clickAssignByName() { + await this.clickButton("Assign by name"); + } + + async clickAssignByNetwork() { + await this.clickButton("Assign by network"); + } + + async clickAssignManually() { + await this.clickButton("Assign manually"); + } + + async selectRackMiner(ipAddress: string) { + await this.clickMinerRow(ipAddress); + } + + async clickRackSlot(slotNumber: number) { + await this.getRackSlot(slotNumber).click(); + } + + async clickRackSlotMenuItem(menuItemLabel: "Search miners" | "Select from list") { + await this.page.getByRole("menuitem", { name: menuItemLabel, exact: true }).click(); + } + + async assignSearchMinerByIpAddress(ipAddress: string) { + await this.validateTitleInModal("Search miners"); + await this.modalMinerList.waitForListToLoad(); + await this.modalMinerList.selectRowByCellText("ipAddress", ipAddress); + await this.clickIn("Assign", "modal"); + await this.validateTitleInModalNotVisible("Search miners"); + } + + async validateMinersAssignedByName(miners: readonly RackSelectorMiner[]) { + const expectedPositions = this.getExpectedPositionsForAssignByName(miners); + + for (let i = 0; i < miners.length; i++) { + const minerRow = this.getAssignedMinerRow(miners[i].ipAddress); + await expect(minerRow.getByTestId("checkmark-icon")).toBeVisible(); + await expect(minerRow.getByTestId("rack-miner-position")).toHaveText( + `Position ${String(expectedPositions[i]).padStart(2, "0")}`, + ); + } + + await this.validateRackSlotsHighlighted(expectedPositions); + } + + async validateMinersAssignedByNetwork(miners: readonly RackSelectorMiner[]) { + const sortedMiners = this.getMinersSortedByIpAddress(miners); + + for (let i = 0; i < sortedMiners.length; i++) { + const row = this.getAssignedMinerRowByPosition(i + 1); + await expect(row.getByTestId("rack-miner-name")).toHaveText(sortedMiners[i].sortName); + await expect(row.getByTestId("rack-miner-subtitle")).toContainText(sortedMiners[i].ipAddress); + } + + await this.validateRackSlotsHighlighted(sortedMiners.map((_, index) => index + 1)); + } + + async assignMinersToSlotsInDomOrder(miners: readonly RackSelectorMiner[]) { + for (let i = 0; i < miners.length; i++) { + await this.clickMinerRow(miners[i].ipAddress); + await this.clickRackSlotByDomIndex(i); + } + } + + async validateRackSlotNumbersInDomOrder(expectedNumbers: readonly number[]) { + const expectedTexts = expectedNumbers.map((value) => String(value).padStart(2, "0")); + await expect(this.page.locator('[data-testid^="rack-slot-"] span.font-medium')).toHaveText(expectedTexts); + } + + async validateMinerPositions(miners: readonly RackSelectorMiner[], expectedPositions: readonly number[]) { + for (let i = 0; i < miners.length; i++) { + await this.validateMinerRowPosition(miners[i].ipAddress, expectedPositions[i]); + } + } + + async validateMinerRowHasGreenCheck(ipAddress: string) { + const minerRow = this.getAssignedMinerRow(ipAddress); + await expect(minerRow.getByTestId("checkmark-icon")).toBeVisible(); + } + + async validateMinerRowUnassigned(ipAddress: string) { + const minerRow = this.getAssignedMinerRow(ipAddress); + await expect(minerRow.getByTestId("checkmark-icon")).toHaveCount(0); + await expect(minerRow.getByTestId("rack-miner-position")).toHaveCount(0); + } + + async validateMinerRowPosition(ipAddress: string, position: number) { + const minerRow = this.getAssignedMinerRow(ipAddress); + await expect(minerRow).toContainText(`Position ${String(position).padStart(2, "0")}`); + } + + async validateRackSlotsHighlighted(slotNumbers: readonly number[]) { + for (const slotNumber of slotNumbers) { + const slot = this.getRackSlot(slotNumber); + await expect(slot).toHaveAttribute("data-slot-state", "assigned"); + } + } + + async validateRackSlotsNotHighlighted(slotNumbers: readonly number[]) { + for (const slotNumber of slotNumbers) { + const slot = this.getRackSlot(slotNumber); + await expect(slot).toHaveAttribute("data-slot-state", "empty"); + } + } + + async clickClearAssignments() { + await this.page.getByRole("button", { name: "Clear", exact: true }).click(); + } + + async clickSaveRack() { + await this.clickButton("Save"); + } + + async clickViewMiners() { + await this.clickButton("View miners"); + await expect(this.page).toHaveURL(/.*\/miners/); + } + + async clickEditRackSettings() { + const overflowTrigger = this.page.getByTestId("overflow-menu-trigger"); + if (this.isMobile && (await overflowTrigger.isVisible().catch(() => false))) { + await overflowTrigger.click(); + } + + await this.clickButton("Edit Rack Settings"); + await this.validateTitleInModal("Rack settings"); + } + + async changeOrderIndexAndContinue(orderIndexLabel: string) { + await this.selectOption("order-index-select", orderIndexLabel); + await this.clickContinueFromRackSettings(); + } + + async validateRackToast(label: string, action: "created" | "updated" = "created") { + await this.validateTextInToast(`Rack "${label}" ${action}`); + } + + async validateRackCardVisible(label: string, zone: string) { + await expect(this.getRackCard(label, zone)).toBeVisible(); + } + + async validateRackCardGrid(label: string, zone: string, columns: number, rows: number) { + const rackCard = this.getRackCard(label, zone); + const miniGridCells = rackCard.getByTestId("rack-card-grid").getByTestId("rack-card-slot"); + await expect(miniGridCells).toHaveCount(columns * rows); + } + + async openRackCard(label: string, zone: string) { + await this.getRackCard(label, zone).click(); + } + + async clickViewList() { + await this.clickButton("View list"); + } + + async clickViewGrid() { + await this.clickButton("View grid"); + } + + async applyZoneFilter(zoneNames: string[]) { + await this.clickVisibleFilterDropdown("Zone"); + const popover = this.page.getByTestId("dropdown-filter-popover"); + await expect(popover).toBeVisible(); + + await popover.getByRole("button", { name: "Reset", exact: true }).click(); + + for (const zoneName of zoneNames) { + await this.clickDropdownFilterOption(popover, zoneName); + } + + await popover.getByRole("button", { name: "Apply", exact: true }).click(); + await expect(popover).toBeHidden(); + } + + async toggleAllZoneFilters() { + await this.clickVisibleFilterDropdown("Zone"); + const popover = this.page.getByTestId("dropdown-filter-popover"); + await expect(popover).toBeVisible(); + await popover.getByText("Select all", { exact: true }).click(); + await popover.getByRole("button", { name: "Apply", exact: true }).click(); + await expect(popover).toBeHidden(); + } + + async selectGridSort(sortLabel: string) { + await this.clickVisibleFilterDropdown("Sort"); + const popover = this.page.getByTestId("dropdown-filter-popover"); + await expect(popover).toBeVisible(); + await this.clickDropdownFilterOption(popover, sortLabel); + if (await popover.isVisible().catch(() => false)) { + await this.clickVisibleFilterDropdown("Sort"); + } + await expect(popover).toBeHidden(); + } + + async waitForRackListToLoad({ allowEmpty = true }: { allowEmpty?: boolean } = {}) { + await expect(this.page.getByRole("button", { name: "Add rack" }).first()).toBeVisible(); + + const rows = this.page.getByTestId("list-row"); + const noRowsText = this.page.getByText("You haven't set up any racks"); + + if (!allowEmpty) { + await expect(rows).not.toHaveCount(0); + } + + await expect(async () => { + const isEmptyStateVisible = await noRowsText.isVisible().catch(() => false); + if (isEmptyStateVisible) { + return; + } + + const rowCount = await rows.count(); + await new Promise((resolve) => setTimeout(resolve, DEFAULT_INTERVAL)); + const rowCountAfterDelay = await rows.count(); + // eslint-disable-next-line playwright/prefer-to-have-count -- intentionally non-retrying: verifies count has stabilized + expect(rowCountAfterDelay).toBe(rowCount); + }).toPass({ timeout: DEFAULT_TIMEOUT, intervals: [DEFAULT_INTERVAL] }); + } + + async listRackNames(): Promise { + await this.waitForRackListToLoad(); + + const nameCells = this.page.getByTestId("list-row").getByTestId("name"); + const count = await nameCells.count(); + const names: string[] = []; + + for (let i = 0; i < count; i++) { + names.push((await nameCells.nth(i).innerText()).trim()); + } + + return names; + } + + async getGridRackLabels(): Promise { + const labels = this.page.locator('[data-testid="rack-card-label"]:visible'); + return (await labels.allTextContents()).map((label) => label.trim()).filter(Boolean); + } + + async validateRackRow(label: string, zone: string, miners: number) { + const row = this.getRackListRow(label); + await expect(row).toBeVisible(); + await expect(row.getByTestId("zone")).toHaveText(zone); + await expect(row.getByTestId("miners")).toHaveText(String(miners)); + } + + async openRackFromList(label: string) { + const row = this.getRackListRow(label); + await expect(row).toBeVisible(); + await row.getByTestId("name").getByRole("button", { name: label, exact: true }).click(); + } + + async clickEditRack() { + await this.clickButton("Edit rack"); + } + + async clickDeleteRack() { + const overflowTrigger = this.page.getByTestId("overflow-menu-trigger"); + if (this.isMobile && (await overflowTrigger.isVisible().catch(() => false))) { + await overflowTrigger.click(); + } + + await this.clickButton("Delete Rack"); + } + + async clickDeleteConfirm() { + await this.clickButton("Delete"); + } + + async validateRackDeletedToast() { + await this.validateTextInToast("Rack deleted"); + } + + async validateRackOverviewAssignedSlots(slotNumbers: readonly number[]) { + for (const slotNumber of slotNumbers) { + const slot = this.getRackOverviewSlot(slotNumber); + await expect(slot).not.toHaveAttribute("data-slot-state", "empty"); + await expect(slot.getByTestId("rack-detail-slot-empty-action")).toHaveCount(0); + + const slotNumberLabel = slot.getByTestId("rack-detail-slot-number"); + if ((await slotNumberLabel.count()) > 0) { + await expect(slotNumberLabel).toHaveText(String(slotNumber).padStart(2, "0")); + } + } + } + + async validateRackOverviewEmptySlots(slotNumbers: readonly number[]) { + for (const slotNumber of slotNumbers) { + const slot = this.getRackOverviewSlot(slotNumber); + await expect(slot).toHaveAttribute("data-slot-state", "empty"); + await expect(slot.getByTestId("rack-detail-slot-empty-action")).toBeVisible(); + } + } + + async clickRackOverviewEmptySlot(slotNumber: number) { + await this.getRackOverviewSlot(slotNumber).getByTestId("rack-detail-slot-empty-action").click(); + await this.validateTitleInModal("Search miners"); + } + + private async selectOption(testId: string, optionLabel: string) { + await this.page.getByTestId(testId).click(); + await this.page.getByRole("option", { name: optionLabel, exact: true }).click(); + } + + private async clickDropdownFilterOption(popover: Locator, optionName: string) { + const optionByTestId = popover.getByTestId(`filter-option-${optionName}`).first(); + if (await optionByTestId.isVisible().catch(() => false)) { + await optionByTestId.click(); + return; + } + + await popover.getByText(optionName, { exact: true }).first().click(); + } + + private async clickVisibleFilterDropdown(title: string) { + const dropdowns = this.page.getByTestId(`filter-dropdown-${title}`); + const count = await dropdowns.count(); + + for (let i = 0; i < count; i++) { + const dropdown = dropdowns.nth(i); + if (await dropdown.isVisible().catch(() => false)) { + await dropdown.click(); + return; + } + } + + throw new Error(`No visible ${title} filter dropdown found`); + } + + private getAssignedMinerRow(ipAddress: string): Locator { + return this.page.getByTestId("rack-miner-row").filter({ hasText: ipAddress }).first(); + } + + private getAssignedMinerRowByPosition(position: number): Locator { + return this.page + .getByTestId("rack-miner-row") + .filter({ + has: this.page + .getByTestId("rack-miner-position") + .getByText(`Position ${String(position).padStart(2, "0")}`, { exact: true }), + }) + .first(); + } + + private async clickMinerRow(ipAddress: string) { + await this.getAssignedMinerRow(ipAddress).click(); + } + + private async clickRackSlotByDomIndex(index: number) { + await this.page.locator('[data-testid^="rack-slot-"]').nth(index).click(); + } + + private getRackSlot(slotNumber: number): Locator { + return this.page.getByTestId(new RegExp(`^rack-slot-0*${slotNumber}$`)); + } + + private getRackCard(label: string, zone: string): Locator { + return this.page.getByTestId("rack-card").filter({ hasText: label }).filter({ hasText: zone }).first(); + } + + private getRackOverviewSlot(slotNumber: number): Locator { + return this.page.getByTestId(`rack-detail-slot-${String(slotNumber).padStart(2, "0")}`); + } + + private getExpectedPositionsForAssignByName(miners: readonly RackSelectorMiner[]): number[] { + const sortedMiners = [...miners].sort((left, right) => left.sortName.localeCompare(right.sortName)); + return miners.map((miner) => sortedMiners.findIndex((candidate) => candidate.ipAddress === miner.ipAddress) + 1); + } + + private getMinersSortedByIpAddress(miners: readonly RackSelectorMiner[]): RackSelectorMiner[] { + const padIp = (ipAddress: string) => ipAddress.replace(/\d+/g, (octet) => octet.padStart(3, "0")); + return [...miners].sort((left, right) => padIp(left.ipAddress).localeCompare(padIp(right.ipAddress))); + } + + private getRackListRow(label: string): Locator { + return this.page + .getByTestId("list-row") + .filter({ has: this.page.getByTestId("name").getByRole("button", { name: label, exact: true }) }) + .first(); + } +} diff --git a/client/e2eTests/protoFleet/pages/settings.ts b/client/e2eTests/protoFleet/pages/settings.ts new file mode 100644 index 000000000..09c8c41e2 --- /dev/null +++ b/client/e2eTests/protoFleet/pages/settings.ts @@ -0,0 +1,36 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "./base"; + +export class SettingsPage extends BasePage { + async clickTemperatureButton() { + await this.page.locator('[data-testid="temperature-button"]').click(); + } + + async selectFahrenheit() { + await this.page.getByTestId("fahrenheit-option").click(); + } + + async selectCelsius() { + await this.page.getByTestId("celsius-option").click(); + } + + async clickDoneButton() { + await this.clickButton("Done"); + } + + async getCurrentTemperatureFormat(): Promise { + return await this.page.locator('[data-testid="temperature-button"]').innerText(); + } + + private async validateTemperatureFormat(format: string) { + await expect(this.page.locator('[data-testid="temperature-button"]')).toHaveText(format); + } + + async validateTemperatureFormatFahrenheit() { + await this.validateTemperatureFormat("Fahrenheit"); + } + + async validateTemperatureFormatCelsius() { + await this.validateTemperatureFormat("Celsius"); + } +} diff --git a/client/e2eTests/protoFleet/pages/settingsApiKeys.ts b/client/e2eTests/protoFleet/pages/settingsApiKeys.ts new file mode 100644 index 000000000..848155b1e --- /dev/null +++ b/client/e2eTests/protoFleet/pages/settingsApiKeys.ts @@ -0,0 +1,155 @@ +import { expect } from "@playwright/test"; +import { DEFAULT_INTERVAL, DEFAULT_TIMEOUT } from "../config/test.config"; +import { BasePage } from "./base"; + +export class SettingsApiKeysPage extends BasePage { + async waitForApiKeysListToLoad() { + const rows = this.page.getByTestId("list-body").getByTestId("list-row"); + const emptyState = this.page.getByText( + "No API keys yet. Create your first key to enable programmatic access to the Fleet API.", + ); + + await expect(this.page.getByText("Loading API keys...")).toBeHidden(); + await expect(this.page.getByRole("button", { name: "Create API key" })).toBeVisible(); + + await expect(async () => { + if (await emptyState.isVisible().catch(() => false)) { + return; + } + + const rowCount = await rows.count(); + if (rowCount === 0) { + throw new Error("API keys list is still loading"); + } + + expect(rowCount).toBeGreaterThan(0); + await new Promise((resolve) => setTimeout(resolve, DEFAULT_INTERVAL)); + + if (await emptyState.isVisible().catch(() => false)) { + return; + } + + const rowCountAfterDelay = await rows.count(); + // eslint-disable-next-line playwright/prefer-to-have-count -- intentionally non-retrying: verifies count has stabilized + expect(rowCountAfterDelay).toBe(rowCount); + }).toPass({ timeout: DEFAULT_TIMEOUT, intervals: [DEFAULT_INTERVAL] }); + } + + async validateApiKeysPageOpened() { + await expect(this.page).toHaveURL(/.*\/settings\/api-keys/); + await this.validateTitle("API Keys"); + await this.validateButtonIsVisible("Create API key"); + } + + async clickCreateApiKey() { + await this.clickButton("Create API key"); + } + + async inputApiKeyName(name: string) { + await this.page.locator("#api-key-name").fill(name); + } + + async clickCreateInModal() { + await this.page.getByTestId("modal").getByRole("button", { name: "Create", exact: true }).click(); + } + + async validateApiKeyNameRequired() { + await this.validateTextInModal("Name is required"); + } + + async openExpirationDatePicker() { + const trigger = this.page.getByTestId("api-key-expires-trigger"); + + if ((await trigger.getAttribute("aria-expanded")) !== "true") { + await trigger.click(); + } + + await expect(trigger).toHaveAttribute("aria-expanded", "true"); + } + + async validateExpirationDayDisabled(day: number) { + await expect(this.page.getByTestId(`api-key-expires-calendar-day-${day}`)).toBeDisabled(); + } + + async selectExpirationDate(date: Date) { + const today = new Date(); + const monthDelta = (date.getFullYear() - today.getFullYear()) * 12 + (date.getMonth() - today.getMonth()); + const calendar = this.page.getByTestId("api-key-expires-calendar"); + + await this.openExpirationDatePicker(); + await expect(calendar).toBeVisible(); + + for (let i = 0; i < Math.max(monthDelta, 0); i += 1) { + await this.page.getByTestId("api-key-expires-calendar-next-month").click(); + } + + for (let i = 0; i < Math.max(-monthDelta, 0); i += 1) { + await this.page.getByTestId("api-key-expires-calendar-prev-month").click(); + } + + await expect(calendar).toBeVisible(); + await this.page.getByTestId(`api-key-expires-calendar-day-${date.getDate()}`).click(); + } + + async validateApiKeyCreated() { + await expect(this.page.getByText("API key created")).toBeVisible(); + await expect(this.page.getByTestId("api-key-value")).not.toHaveText(""); + } + + async clickDone() { + await this.clickButton("Done"); + } + + async validateApiKeyVisible(name: string) { + await expect(this.getApiKeyRow(name)).toBeVisible(); + } + + async validateApiKeyHasNoExpiration(name: string) { + await expect(this.getApiKeyRow(name).getByTestId("expiresAt")).toHaveText("Never"); + } + + async validateApiKeyHasExpiration(name: string) { + await expect(this.getApiKeyRow(name).getByTestId("expiresAt")).not.toHaveText("Never"); + } + + async clickRevokeApiKey(name: string) { + await this.getApiKeyRow(name).getByRole("button", { name: "Revoke", exact: true }).click(); + } + + async confirmRevokeApiKey() { + await this.clickButton("Revoke key"); + } + + async validateApiKeyNotVisible(name: string) { + await expect(this.getApiKeyRow(name)).toHaveCount(0); + } + + async deleteApiKeysByPrefix(prefix: string) { + await this.waitForApiKeysListToLoad(); + + const rows = await this.page.getByTestId("list-body").getByTestId("list-row").all(); + const keyNames: string[] = []; + + for (const row of rows) { + const name = (await row.getByTestId("name").textContent())?.trim(); + if (name?.startsWith(prefix)) { + keyNames.push(name); + } + } + + for (const keyName of keyNames) { + await this.clickRevokeApiKey(keyName); + await this.confirmRevokeApiKey(); + await this.validateApiKeyNotVisible(keyName); + } + } + + private getApiKeyRow(name: string) { + return this.page + .getByTestId("list-body") + .getByTestId("list-row") + .filter({ + has: this.page.getByTestId("name").getByText(name, { exact: true }), + }); + } +} diff --git a/client/e2eTests/protoFleet/pages/settingsFirmware.ts b/client/e2eTests/protoFleet/pages/settingsFirmware.ts new file mode 100644 index 000000000..0dd692810 --- /dev/null +++ b/client/e2eTests/protoFleet/pages/settingsFirmware.ts @@ -0,0 +1,61 @@ +import { expect } from "@playwright/test"; +import { DEFAULT_INTERVAL, DEFAULT_TIMEOUT } from "../config/test.config"; +import { BasePage } from "./base"; + +export class SettingsFirmwarePage extends BasePage { + async validateFirmwarePageOpened() { + await expect(this.page).toHaveURL(/.*\/settings\/firmware/); + await this.validateTitle("Firmware"); + } + + async clickUploadFirmware() { + await this.clickButton("Upload firmware"); + await this.validateTitleInModal("Upload firmware"); + } + + async uploadFirmwareFile(fileName: string, fileContents: string) { + await this.page.getByTestId("firmware-file-input").setInputFiles({ + name: fileName, + mimeType: "application/octet-stream", + buffer: Buffer.from(fileContents), + }); + } + + async clickDoneInUploadDialog() { + await this.clickIn("Done", "modal"); + } + + async validateFirmwareFileVisible(fileName: string) { + await expect(this.page.getByTestId("list-body").locator("tr").filter({ hasText: fileName })).toBeVisible(); + } + + async deleteAllFirmwareFilesIfAny() { + const emptyState = this.page.getByText("No firmware files uploaded.", { exact: true }); + const firmwareRows = this.page.getByTestId("list-body").locator("tr"); + const loadingState = this.page.getByText("Loading firmware files...", { exact: true }); + const deleteAllButton = this.page.getByRole("button", { name: "Delete all", exact: true }).first(); + + if (await loadingState.isVisible().catch(() => false)) { + await expect(loadingState).toBeHidden(); + } + + await expect(async () => { + const emptyVisible = await emptyState.isVisible().catch(() => false); + const hasRows = (await firmwareRows.count()) > 0; + + expect(emptyVisible || hasRows).toBeTruthy(); + }).toPass({ timeout: DEFAULT_TIMEOUT, intervals: [DEFAULT_INTERVAL] }); + + if (await emptyState.isVisible().catch(() => false)) { + return; + } + + await expect(deleteAllButton).toBeEnabled(); + await deleteAllButton.click(); + const deleteAllDialog = this.page.getByTestId("delete-all-firmware-dialog"); + await deleteAllDialog.getByRole("button", { name: "Delete all" }).click(); + await expect(deleteAllDialog).toBeHidden(); + await expect(deleteAllButton).toBeDisabled(); + await expect(emptyState).toBeVisible(); + } +} diff --git a/client/e2eTests/protoFleet/pages/settingsPools.ts b/client/e2eTests/protoFleet/pages/settingsPools.ts new file mode 100644 index 000000000..e439d5e77 --- /dev/null +++ b/client/e2eTests/protoFleet/pages/settingsPools.ts @@ -0,0 +1,35 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "./base"; + +export class SettingsPoolsPage extends BasePage { + async validateMiningPoolsPageOpened() { + await expect(this.page).toHaveURL(/.*\/mining-pools/); + await this.validateButtonIsVisible("Add pool"); + } + + async clickAddPool() { + await this.clickButton("Add pool"); + } + + async validatePoolEntryByUniqueName(expectedName: string, expectedUrl: string, expectedUsername: string) { + await expect(this.page.getByTestId(`pool-row`).getByTestId("pool-name").getByText(expectedName)).toBeVisible(); + const row = this.page + .getByTestId(`pool-row`) + .filter({ has: this.page.getByTestId("pool-name").getByText(expectedName) }); + await expect(row.getByTestId("pool-url").getByText(expectedUrl)).toBeVisible(); + await expect(row.getByTestId("pool-username").getByText(expectedUsername)).toBeVisible(); + } + + async deleteAllPools() { + const poolRows = this.page.getByTestId("pool-row"); + const poolCount = await poolRows.count(); + + for (let i = 0; i < poolCount; i++) { + const firstRow = poolRows.first(); + await firstRow.getByRole("button", { name: "Options menu", exact: true }).click(); + await this.clickButton("Delete pool"); + await expect(poolRows).toHaveCount(poolCount - 1 - i); + } + await expect(poolRows).toHaveCount(0); + } +} diff --git a/client/e2eTests/protoFleet/pages/settingsSchedules.ts b/client/e2eTests/protoFleet/pages/settingsSchedules.ts new file mode 100644 index 000000000..e6ae9981e --- /dev/null +++ b/client/e2eTests/protoFleet/pages/settingsSchedules.ts @@ -0,0 +1,224 @@ +import { expect } from "@playwright/test"; +import { DEFAULT_INTERVAL, DEFAULT_TIMEOUT } from "../config/test.config"; +import { BasePage } from "./base"; +import { ModalMinerSelectionList } from "./components/modalMinerSelectionList"; + +export class SettingsSchedulesPage extends BasePage { + private readonly modalMinerList = new ModalMinerSelectionList(this.page.getByTestId("modal")); + + async validateSchedulesPageOpened() { + await expect(this.page).toHaveURL(/.*\/settings\/schedules/); + await this.validateTitle("Schedules"); + await this.validateButtonIsVisible("Add a schedule"); + } + + async clickAddSchedule() { + await this.clickButton("Add a schedule"); + await this.validateTitle("Add a schedule"); + } + + async inputScheduleName(name: string) { + await this.page.locator("#schedule-name").fill(name); + } + + async selectActionType(label: string) { + await this.selectOption("#schedule-action", "Action type", label); + } + + async selectScheduleType(label: string) { + await this.selectOption("#schedule-type", "Type", label); + } + + async selectScheduleFrequency(label: string) { + await this.selectOption("#schedule-frequency", "Frequency", label); + } + + async validateSaveDisabled() { + await expect(this.page.getByRole("button", { name: "Save", exact: true })).toBeDisabled(); + } + + async validateSaveEnabled() { + await expect(this.page.getByRole("button", { name: "Save", exact: true })).toBeEnabled(); + } + + async inputDayOfMonth(value: string) { + const input = this.page.locator("#schedule-day-of-month"); + await input.fill(value); + await input.blur(); + } + + async validateValidationMessage(text: string) { + await expect(this.page.getByText(text, { exact: true })).toBeVisible(); + } + + async openWeekdaySelect() { + await this.page.locator("#schedule-days-of-week").click(); + } + + async selectWeekday(label: string) { + await this.openWeekdaySelect(); + await this.page.getByRole("option", { name: label, exact: true }).click(); + await this.page.locator("#schedule-days-of-week").click(); + await expect(this.page.getByRole("listbox", { name: "Days options" })).toBeHidden(); + } + + async selectStartDate(daysFromToday: number) { + const today = new Date(); + const target = new Date(); + target.setDate(target.getDate() + daysFromToday); + const monthDelta = (target.getFullYear() - today.getFullYear()) * 12 + (target.getMonth() - today.getMonth()); + + await this.page.getByTestId("schedule-start-date-trigger").click(); + await expect(this.page.getByTestId("schedule-start-date-calendar")).toBeVisible(); + + for (let i = 0; i < Math.max(monthDelta, 0); i += 1) { + await this.page.getByTestId("schedule-start-date-calendar-next-month").click(); + } + + for (let i = 0; i < Math.max(-monthDelta, 0); i += 1) { + await this.page.getByTestId("schedule-start-date-calendar-prev-month").click(); + } + + await this.page.getByTestId(`schedule-start-date-calendar-day-${target.getDate()}`).click(); + } + + async openMinersTargetSelector() { + await this.page + .locator("button") + .filter({ has: this.page.getByText("Miners", { exact: true }) }) + .first() + .click(); + await this.validateTitleInModal("Select miners"); + } + + async waitForMinerSelectionModalToLoad() { + await this.modalMinerList.waitForListToLoad(); + } + + async selectFirstMiners(count: number) { + const indexes = await this.modalMinerList.getSelectableRowIndexes(count); + if (indexes.length < count) { + throw new Error(`Expected at least ${count} selectable miners, found ${indexes.length}`); + } + + await this.modalMinerList.selectRowsByIndex(indexes); + } + + async confirmMinerSelection() { + await this.page.getByTestId("modal").getByRole("button", { name: "Done", exact: true }).click(); + await expect(this.page.getByTestId("modal")).toBeHidden(); + } + + async clickSaveSchedule() { + await this.clickButton("Save"); + } + + async validateScheduleVisible(name: string) { + await expect(this.getScheduleRow(name)).toBeVisible(); + } + + async validateScheduleNotVisible(name: string) { + await expect(this.getScheduleRows(name)).toHaveCount(0); + } + + async validateScheduleStatus(name: string, expectedStatus: string) { + await expect(this.getScheduleRow(name).getByTestId("status")).toContainText(expectedStatus); + } + + async validateScheduleAction(name: string, expectedAction: string) { + await expect(this.getScheduleRow(name).getByTestId("action").first()).toHaveText(expectedAction); + } + + async validateScheduleSummary(name: string, expectedSummary: string) { + await expect(this.getScheduleRow(name).getByTestId("schedule")).toContainText(expectedSummary); + } + + async validateScheduleTargetSummary(name: string, expectedSummary: string) { + await expect(this.getScheduleRow(name).getByTestId("name")).toContainText(expectedSummary); + } + + async openScheduleActions(name: string) { + const row = this.getScheduleRow(name); + await expect(row).toBeVisible(); + await row.getByTestId("list-actions-trigger").click(); + } + + async clickScheduleAction(actionName: string) { + await this.page.getByText(actionName, { exact: true }).click(); + } + + async openEditSchedule(name: string) { + await this.openScheduleActions(name); + await this.clickScheduleAction("Edit"); + await this.validateTitle("Edit schedule"); + } + + async pauseSchedule(name: string) { + await this.openScheduleActions(name); + await this.clickScheduleAction("Pause"); + } + + async resumeSchedule(name: string) { + await this.openScheduleActions(name); + await this.clickScheduleAction("Resume"); + } + + async deleteSchedule(name: string) { + await this.openScheduleActions(name); + await this.clickScheduleAction("Delete"); + await this.validateScheduleNotVisible(name); + } + + async waitForSchedulesListToLoad() { + const rows = this.page.getByTestId("list-row"); + const emptyState = this.page.getByText("Configure schedules to automate actions for your miners."); + + await expect(this.page.getByRole("button", { name: "Add a schedule" })).toBeVisible(); + + if (await emptyState.isVisible().catch(() => false)) { + return; + } + + await expect(async () => { + const rowCount = await rows.count(); + await new Promise((resolve) => setTimeout(resolve, DEFAULT_INTERVAL)); + const rowCountAfterDelay = await rows.count(); + // eslint-disable-next-line playwright/prefer-to-have-count -- intentionally non-retrying: verifies count has stabilized + expect(rowCountAfterDelay).toBe(rowCount); + }).toPass({ timeout: DEFAULT_TIMEOUT, intervals: [DEFAULT_INTERVAL] }); + } + + async deleteSchedulesByPrefix(prefix: string) { + await this.waitForSchedulesListToLoad(); + + const rows = await this.page.getByTestId("list-row").all(); + const scheduleNames: string[] = []; + + for (const row of rows) { + const name = (await row.getByTestId("name").locator("span").first().textContent())?.trim(); + if (name?.startsWith(prefix)) { + scheduleNames.push(name); + } + } + + for (const scheduleName of scheduleNames) { + await this.deleteSchedule(scheduleName); + } + } + + private getScheduleRow(name: string) { + return this.getScheduleRows(name).first(); + } + + private getScheduleRows(name: string) { + return this.page.getByTestId("list-row").filter({ + has: this.page.getByTestId("name").getByText(name, { exact: true }), + }); + } + + private async selectOption(triggerSelector: string, label: string, optionLabel: string) { + await this.page.locator(triggerSelector).click(); + await this.page.getByRole("option", { name: optionLabel, exact: true }).click(); + await expect(this.page.getByRole("button", { name: label, exact: true })).toContainText(optionLabel); + } +} diff --git a/client/e2eTests/protoFleet/pages/settingsSecurity.ts b/client/e2eTests/protoFleet/pages/settingsSecurity.ts new file mode 100644 index 000000000..f6d53192d --- /dev/null +++ b/client/e2eTests/protoFleet/pages/settingsSecurity.ts @@ -0,0 +1,52 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "./base"; + +export class SettingsSecurityPage extends BasePage { + async clickUpdateUsername() { + await this.clickIn("Update", "username-row"); + } + + async clickUpdatePassword() { + await this.clickIn("Update", "password-row"); + } + + async inputCurrentPassword(password: string) { + await this.page.locator(`//input[@id='currentPassword']`).fill(password); + } + + async clickConfirm() { + await this.clickIn("Confirm", "modal"); + } + + async inputNewUsername(username: string) { + await this.page.locator(`//input[@id='newUsername']`).fill(username); + } + + async clickConfirmUsername() { + await this.clickIn("Confirm", "modal"); + } + + async validateUsernameChangeToast() { + await expect(this.page.getByText(`Username updated`)).toBeVisible(); + } + + async validateUsername(username: string) { + await expect(this.page.getByTestId("username-value")).toHaveText(username); + } + + async inputNewPassword(password: string) { + await this.page.locator(`//input[@id='newPassword']`).fill(password); + } + + async inputConfirmPassword(password: string) { + await this.page.locator(`//input[@id='confirmPassword']`).fill(password); + } + + async clickConfirmPassword() { + await this.clickIn("Confirm", "modal"); + } + + async validatePasswordChangeToast() { + await expect(this.page.getByText(`Password updated`)).toBeVisible(); + } +} diff --git a/client/e2eTests/protoFleet/pages/settingsTeam.ts b/client/e2eTests/protoFleet/pages/settingsTeam.ts new file mode 100644 index 000000000..34b58f262 --- /dev/null +++ b/client/e2eTests/protoFleet/pages/settingsTeam.ts @@ -0,0 +1,110 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "./base"; + +export class SettingsTeamPage extends BasePage { + async validateTeamSettingsPageOpened() { + await expect(this.page).toHaveURL(/.*\/team/); + await this.validateTitle("Team"); + } + + async validateIsAdmin() { + await expect(this.page.getByRole("button", { name: "Add team member" })).toBeVisible(); + } + + async clickAddTeamMember() { + await this.clickButton("Add team member"); + } + + async inputMemberUsername(username: string) { + await this.page.locator(`//input[@id='username']`).fill(username); + } + + async clickSaveTeamMember() { + await this.clickButton("Save"); + } + + async validateMemberAdded() { + await expect(this.page.getByTestId("modal").getByText("Member added")).toBeVisible(); + } + + async validateCopyPasswordButtonVisible() { + await expect(this.page.locator(`//button[@aria-label="Copy password"]`)).toBeVisible(); + } + + async clickDone() { + await this.clickButton("Done"); + } + + async validateMemberRole(username: string, role: string) { + const memberRow = this.page + .getByTestId("list-body") + .locator("tr") + .filter({ + has: this.page.locator(`//td[@data-testid='username']//*[text()='${username}']`), + }); + await expect(memberRow.locator(`//td[@data-testid='role']`)).toHaveText(role); + } + + async validateMemberLastLogin(username: string, lastLogin: string) { + const memberRow = this.page + .getByTestId("list-body") + .locator("tr") + .filter({ + has: this.page.locator(`//td[@data-testid='username']//*[text()='${username}']`), + }); + await expect(memberRow.locator(`//td[@data-testid='lastLoginAt']`)).toHaveText(lastLogin); + } + + async getTemporaryPassword(): Promise { + return await this.page.getByTestId("temporary-password").innerText(); + } + + async validateMemberVisible(username: string) { + await expect(this.page.locator(`//td[@data-testid='username']//*[text()='${username}']`)).toBeVisible(); + } + + async validateNoAdminRights() { + await expect(this.page.getByRole("button", { name: "Add team member" })).toBeHidden(); + } + + async clickMemberActionsMenu(username: string) { + const memberRow = this.page + .getByTestId("list-body") + .locator("tr") + .filter({ + has: this.page.locator(`//td[@data-testid='username']//*[text()='${username}']`), + }); + await memberRow.getByTestId("list-actions-trigger").click(); + } + + async clickResetPassword() { + await this.clickButton("Reset Password"); + } + + async clickResetMemberPasswordConfirm() { + await this.clickButton("Reset member password"); + } + + async validatePasswordReset() { + await expect(this.page.getByTestId("temporary-password")).toBeVisible(); + await expect(this.page.getByRole("button", { name: "Done", exact: true })).toBeVisible(); + } + + async clickDeactivate() { + await this.clickButton("Deactivate"); + } + + async clickConfirmDeactivation() { + await this.clickButton("Confirm deactivation"); + } + + async validateMemberDeactivatedMessage(username: string) { + await expect( + this.page.locator(`//*[contains(@class,'heading')][contains(text(),'${username} has been deactivated')]`), + ).toBeVisible(); + } + + async validateMemberNotInList(username: string) { + await expect(this.page.locator(`//td[@data-testid='username']//*[text()='${username}']`)).toBeHidden(); + } +} diff --git a/client/e2eTests/protoFleet/playwright.config.ts b/client/e2eTests/protoFleet/playwright.config.ts new file mode 100644 index 000000000..e4c70a4c0 --- /dev/null +++ b/client/e2eTests/protoFleet/playwright.config.ts @@ -0,0 +1,71 @@ +import { defineConfig } from "@playwright/test"; +import { testConfig } from "./config/test.config"; + +/** + * See https://playwright.dev/docs/test-configuration. + */ + +export default defineConfig({ + testDir: "./spec", + /* Run tests in serial order (one at a time) */ + fullyParallel: false, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: 0, + /* Opt out of parallel tests on CI for more stability */ + workers: 1, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: process.env.CI + ? [ + ["html", { outputFolder: "playwright-report", open: "never" }], + ["github"], + ["junit", { outputFile: "test-results/results.xml" }], + ] + : "html", + /* Global timeout for each test */ + timeout: testConfig.testTimeout, + /* Set default timeout for all expect() assertions */ + expect: { + timeout: testConfig.actionTimeout, + }, + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: testConfig.baseUrl, + + /* Set a consistent viewport size for all tests */ + viewport: { width: 1600, height: 900 }, + + /* Set default timeout for actions like click, fill, etc. */ + actionTimeout: testConfig.actionTimeout, + + /* Capture screenshots (only on failure) and video (retain on failure) so they appear in the HTML report */ + screenshot: "only-on-failure", + video: "retain-on-failure", + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + }, + + // E.g.: npx playwright test --project=desktop + projects: [ + { + name: "desktop", + testMatch: /.*\.spec\.ts/, + use: { + viewport: { width: 1600, height: 900 }, + isMobile: false, + }, + }, + // Resolution of the iPhone 14 Pro / 15 Pro / 16 + { + name: "mobile", + testMatch: /.*\.spec\.ts/, + use: { + viewport: { width: 393, height: 852 }, + isMobile: true, + }, + }, + ], +}); diff --git a/client/e2eTests/protoFleet/spec/00-onboarding.spec.ts b/client/e2eTests/protoFleet/spec/00-onboarding.spec.ts new file mode 100644 index 000000000..2c68cb475 --- /dev/null +++ b/client/e2eTests/protoFleet/spec/00-onboarding.spec.ts @@ -0,0 +1,184 @@ +/* eslint-disable playwright/expect-expect */ +import { testConfig } from "../config/test.config"; +import { expect, test } from "../fixtures/pageFixtures"; + +test.describe("Proto Fleet - Onboarding", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test("Onboard the admin user @setup", async ({ authPage }) => { + await test.step("Create credentials", async () => { + await authPage.inputUsername(testConfig.users.admin.username); + await authPage.inputPassword(testConfig.users.admin.password); + await authPage.clickContinue(); + }); + + await test.step("Validate admin is logged in", async () => { + await authPage.validateLoggedIn(); + }); + }); + + test("Validate null states", async ({ homePage, commonSteps, minersPage, groupsPage, settingsPoolsPage }) => { + await commonSteps.loginAsAdmin(); + + await test.step("Validate Home screen null state due to no miners added", async () => { + await homePage.validateTextIsVisible("Let's setup your fleet."); + await homePage.validateTextIsVisible("Add miners to your fleet to get started."); + await homePage.validateButtonIsVisible("Get Started"); + }); + + await test.step("Validate Miners screen null state due to no miners added", async () => { + await homePage.navigateToMinersPage(); + await minersPage.validateTextIsVisible("You haven't paired any miners"); + await minersPage.validateTextIsVisible("Add miners to your fleet to get started."); + await minersPage.validateButtonIsVisible("Get Started"); + }); + + await test.step("Validate Groups screen null state due to no groups added", async () => { + await minersPage.navigateToGroupsPage(); + await groupsPage.validateTextIsVisible("Organize your miners into groups."); + await groupsPage.validateButtonIsVisible("Add group"); + }); + + await test.step("Validate Pools screen null state due to no pools added", async () => { + await groupsPage.navigateToMiningPoolsSettings(); + await settingsPoolsPage.validateTitle("Pools"); + await settingsPoolsPage.validateTextIsVisible("Add a pool to start assigning your miners."); + await settingsPoolsPage.validateButtonIsVisible("Add pool"); + }); + }); + + if (testConfig.target === "real") { + test("Add specific miners @setup", async ({ authPage, minersPage, commonSteps, addMinersPage }) => { + await commonSteps.loginAsAdmin(); + + await test.step("Get started with onboarding", async () => { + await authPage.clickGetStarted(); + }); + + const rawMinerIps = process.env.E2E_MINER_IPS || ""; + const minerIps = rawMinerIps + .split(",") + .map((ip) => ip.trim()) + .filter(Boolean); + expect( + minerIps, + "E2E_MINER_IPS must be a comma-separated list of miner IPs, e.g. '192.168.1.10,192.168.1.11'.", + ).not.toHaveLength(0); + + const listOfMiners = minerIps.join(","); + console.warn("Running onboarding test with the following miner IPs:", minerIps); + const amountOfMiners = minerIps.length; + + await test.step("Find and add miners", async () => { + await addMinersPage.inputMinerIp(listOfMiners); + await addMinersPage.clickFindMinersByIp(); + await addMinersPage.clickContinueWithXMiners(amountOfMiners); + }); + + await commonSteps.goToMinersPage(); + + await test.step("Validate miners added", async () => { + await minersPage.validateMinersAdded(amountOfMiners); + }); + }); + } else { + test("Add all scanned miners @setup", async ({ authPage, minersPage, commonSteps, addMinersPage }) => { + await commonSteps.loginAsAdmin(); + + await test.step("Get started with onboarding", async () => { + await authPage.clickGetStarted(); + }); + + await test.step("Find and add miners", async () => { + await addMinersPage.clickFindMinersInNetwork(); + await addMinersPage.clickContinueWithSelectedMiners(); + }); + + await commonSteps.goToMinersPage(); + + await test.step("Validate miners added", async () => { + await minersPage.validateMinersAdded(); + }); + }); + } + + if (testConfig.target !== "real") { + test("Authenticate miners @setup", async ({ homePage, commonSteps }) => { + await commonSteps.loginAsAdmin(); + + await test.step("Start authentication process", async () => { + await homePage.validateCompleteSetupTitle(); + await homePage.clickAuthenticateMinersButton(); + await homePage.validateAuthenticateMinersModalTitle(); + }); + + await test.step("Validate 4 miners need authentication - S17, S19, S19, S21", async () => { + await homePage.validateTextInModal("Bulk authenticate"); + await homePage.validateTextInModal("4 miners remaining"); + await homePage.clickShowMinersButton(); + await homePage.validateTextInModal("Bulk authenticate"); + await homePage.validateTextInModal("4 miners remaining"); + const miners = await homePage.getListOfMinersToAuthenticate(); + expect(miners).toHaveLength(4); + expect(miners).toContain("Antminer S21 XP"); + expect(miners).toContain("Antminer S17 XP"); + expect(miners.filter((model) => model === "Antminer S19 XP")).toHaveLength(2); + }); + + await test.step("Bulk authenticate all miners with S19 credentials", async () => { + await homePage.inputMinerAuthUsername("root19"); + await homePage.inputMinerAuthPassword("root19"); + await homePage.clickAuthenticateMinersConfirmButton(); + }); + + await test.step("Validate S19 miners authenticated, but S21 and S17 not", async () => { + await homePage.validateTextInToast("You authenticated 2 of 4 miners."); + await homePage.validateCalloutInModal("Try your username and password again."); + await homePage.clickCalloutButton(); + const miners = await homePage.getListOfMinersToAuthenticate(); + expect(miners).toHaveLength(2); + expect(miners).toContain("Antminer S21 XP"); + expect(miners).toContain("Antminer S17 XP"); + }); + + await test.step("Try authenticating S21 miner incorrectly with S17 miner's credentials", async () => { + await homePage.clickMinerAuthCheckbox("Antminer S17 XP"); + await homePage.inputMinerRowUsername("Antminer S21 XP", "root17"); + await homePage.inputMinerRowPassword("Antminer S21 XP", "root17"); + await homePage.clickAuthenticateMinersConfirmButton(); + }); + + await test.step("Validate S21 miner's authentication failed", async () => { + await homePage.validateTextInToast("Authentication failed. Please check your credentials and try again."); + await homePage.validateCalloutInModal("Try your username and password again."); + await homePage.clickCalloutButton(); + }); + + await test.step("Authenticating S21 miner", async () => { + await homePage.inputMinerRowUsername("Antminer S21 XP", "root21"); + await homePage.inputMinerRowPassword("Antminer S21 XP", "root21"); + await homePage.clickAuthenticateMinersConfirmButton(); + }); + + await test.step("Validate S21 miner successfully authenticated", async () => { + await homePage.validateTextInToast("1 miner authenticated."); + await homePage.validateNoCalloutInModal(); + }); + + await test.step("Bulk authenticate last miner - S17", async () => { + await homePage.clickMinerAuthCheckbox("Antminer S17 XP"); + await homePage.inputMinerAuthUsername("root17"); + await homePage.inputMinerAuthPassword("root17"); + await homePage.clickAuthenticateMinersConfirmButton(); + }); + + await test.step("Validate all miners authenticated", async () => { + await homePage.validateTextInToast("All miners authenticated."); + await homePage.validateModalClosed(); + await homePage.validateAuthenticateMinersButtonNotVisible(); + }); + }); + } +}); diff --git a/client/e2eTests/protoFleet/spec/01-miningPools.spec.ts b/client/e2eTests/protoFleet/spec/01-miningPools.spec.ts new file mode 100644 index 000000000..1e6ce2a88 --- /dev/null +++ b/client/e2eTests/protoFleet/spec/01-miningPools.spec.ts @@ -0,0 +1,286 @@ +/* eslint-disable playwright/expect-expect */ +import { DEFAULT_INTERVAL, testConfig } from "../config/test.config"; +import { expect, test } from "../fixtures/pageFixtures"; +import { CommonSteps } from "../helpers/commonSteps"; +import { generateRandomText } from "../helpers/testDataHelper"; +import { AuthPage } from "../pages/auth"; +import { LoginModalComponent } from "../pages/components/loginModal"; +import { EditPoolPage } from "../pages/editPool"; +import { MinersPage } from "../pages/miners"; +import { NewPoolModalPage } from "../pages/newPoolModal"; +import { SettingsPage } from "../pages/settings"; +import { SettingsPoolsPage } from "../pages/settingsPools"; + +function generatePoolUsername(): string { + return generateRandomText("PoolUsername"); +} + +test.describe("Mining Pools", () => { + test.beforeEach(async ({ page, settingsPage, settingsPoolsPage, commonSteps }) => { + await page.goto("/"); + + // Clear all existing pools to ensure consistent test state + await commonSteps.loginAsAdmin(); + await settingsPage.navigateToMiningPoolsSettings(); + await settingsPoolsPage.validateMiningPoolsPageOpened(); + await settingsPoolsPage.deleteAllPools(); + await page.goto("/"); + }); + + test.afterAll("CLEANUP: Add default pool to miners", async ({ browser }, testInfo) => { + const isMobile = testInfo.project.use?.isMobile ?? false; + const context = await browser.newContext({ baseURL: testConfig.baseUrl }); + try { + const page = await context.newPage(); + await page.goto("/"); + + const authPage = new AuthPage(page, isMobile); + const minersPage = new MinersPage(page, isMobile); + const editPoolPage = new EditPoolPage(page, isMobile); + const newPoolModal = new NewPoolModalPage(page, isMobile); + const loginModal = new LoginModalComponent(page, isMobile); + const settingsPage = new SettingsPage(page, isMobile); + const settingsPoolsPage = new SettingsPoolsPage(page, isMobile); + const commonSteps = new CommonSteps(authPage, minersPage); + + await commonSteps.loginAsAdmin(); + + await commonSteps.goToMinersPage(); + + const amountOfMiners = await minersPage.getMinersCount(); + if (amountOfMiners > 0) { + await minersPage.clickSelectAllCheckbox(); + await minersPage.clickActionsMenuButton(); + await minersPage.clickEditMiningPoolButton(); + await loginModal.loginAsAdmin(); + + await editPoolPage.clickAddPoolButton(); + await editPoolPage.clickAddNewPool(); + await newPoolModal.inputPoolName("PoolNameDefault"); + await newPoolModal.inputPoolUrl(validPoolUrl); + + await newPoolModal.inputPoolUsername(generateRandomText("Afterhook")); + // await newPoolModal.inputPoolUsername(validUsername); // use when DASH-1407 is fixed + await newPoolModal.clickSaveNewPool(); + await editPoolPage.clickAssignToXMiners(amountOfMiners); + await editPoolPage.validateTextInToastGroup("Assigned pools"); + } + + await settingsPage.navigateToMiningPoolsSettings(); + await settingsPoolsPage.validateMiningPoolsPageOpened(); + + const poolRows = page.getByTestId("pool-row"); + const poolCount = await poolRows.count(); + + for (let i = poolCount - 1; i >= 0; i--) { + const row = poolRows.nth(i); + const poolNameElement = row.getByTestId("pool-name"); + const poolName = await poolNameElement.textContent(); + + if (poolName && poolName.startsWith("PoolName")) { + await row.getByRole("button", { name: "Options menu", exact: true }).click(); + await settingsPoolsPage.clickButton("Delete pool"); + } + } + } finally { + await context.close(); + } + }); + + const invalidPoolUrl = "stratum+tcp://eu1.examplepool.com:3333"; + const validPoolUrl = "stratum+tcp://mine.ocean.xyz:3334"; + // When DASH-1407 is fixed, use a real wallet, so that real miners always have it configured + // Also, removed the actual username for security reasons. Need to get from GH secrets + // const validUsername = "aaaaaaa"; + + test("Configure mining pool", async ({ settingsPage, settingsPoolsPage, newPoolModal }) => { + const settingsPoolName = generateRandomText("PoolName"); + const poolUsername = generatePoolUsername(); + + await test.step("Navigate to mining pools settings", async () => { + await settingsPage.navigateToMiningPoolsSettings(); + await settingsPoolsPage.validateMiningPoolsPageOpened(); + }); + + await test.step("Start adding a pool", async () => { + await settingsPoolsPage.clickAddPool(); + await newPoolModal.validatePoolModalOpened(); + }); + + await test.step("Validate empty pool url message", async () => { + await newPoolModal.clickTestConnection(); + await newPoolModal.validateEmptyPoolUrlError(); + }); + + await test.step("Configure mining pool with invalid URL", async () => { + await newPoolModal.inputPoolName(settingsPoolName); + await newPoolModal.inputPoolUrl(invalidPoolUrl); + await newPoolModal.inputPoolUsername(poolUsername); + }); + + await test.step("Test connection - expect failure", async () => { + await newPoolModal.clickTestConnection(); + await newPoolModal.validateConnectionFailed(); + }); + + await test.step("Change URL to a valid one", async () => { + await newPoolModal.inputPoolUrl(validPoolUrl); + }); + + await test.step("Test connection - expect success", async () => { + await newPoolModal.clickTestConnection(); + await newPoolModal.validateConnectionSuccessful(); + }); + + await test.step("Save and validate pool URL", async () => { + await newPoolModal.clickSaveNewPool(); + await settingsPoolsPage.validatePoolEntryByUniqueName(settingsPoolName, validPoolUrl, poolUsername); + }); + }); + + test("Add default mining pool to all miners @setup", async ({ + minersPage, + editPoolPage, + newPoolModal, + loginModal, + commonSteps, + }) => { + const poolName = generateRandomText("PoolName"); + await commonSteps.goToMinersPage(); + + let amountOfMiners: number; + + await test.step("Select all miners and open pool editor", async () => { + amountOfMiners = await minersPage.getMinersCount(); + await minersPage.clickSelectAllCheckbox(); + await minersPage.clickActionsMenuButton(); + await minersPage.clickEditMiningPoolButton(); + await loginModal.loginAsAdmin(); + }); + + await test.step("Add default mining pool", async () => { + await editPoolPage.clickAddPoolButton(); + await editPoolPage.clickAddNewPool(); + await editPoolPage.validateModalIsOpen(); + await newPoolModal.inputPoolName(poolName); + await newPoolModal.inputPoolUrl(validPoolUrl); + await newPoolModal.inputPoolUsername(generateRandomText("allMinerDefault")); + // await newPoolModal.inputPoolUsername(validUsername); // use when DASH-1407 is fixed + await newPoolModal.clickTestConnection(); + await newPoolModal.validateConnectionSuccessful(); + await newPoolModal.clickSaveNewPool(); + await editPoolPage.validateModalIsClosed(); + await editPoolPage.validatePoolByIndex(0, poolName, validPoolUrl); + await editPoolPage.clickAssignToXMiners(amountOfMiners); + await editPoolPage.validateTextInToastGroup("Assigned pools"); + }); + + await test.step("Validate the pool has been assigned", async () => { + await minersPage.validateNoMinerWithIssue("Pool required"); + }); + }); + + test("Add pool created from settings and reorder", async ({ + settingsPage, + settingsPoolsPage, + newPoolModal, + minersPage, + editPoolPage, + commonSteps, + loginModal, + }) => { + const newPoolName1 = generateRandomText("PoolName1"); + const newPoolName2 = generateRandomText("PoolName2"); + const newPoolUsername1 = generatePoolUsername(); + const newPoolUsername2 = generatePoolUsername(); + + await test.step("Navigate to mining pools settings", async () => { + await settingsPage.navigateToMiningPoolsSettings(); + await settingsPoolsPage.validateMiningPoolsPageOpened(); + }); + + await test.step("Add a pool", async () => { + await settingsPoolsPage.clickAddPool(); + await newPoolModal.inputPoolName(newPoolName1); + await newPoolModal.inputPoolUrl(validPoolUrl); + await newPoolModal.inputPoolUsername(newPoolUsername1); + await newPoolModal.clickSaveNewPool(); + await settingsPoolsPage.validatePoolEntryByUniqueName(newPoolName1, validPoolUrl, newPoolUsername1); + await settingsPoolsPage.validateTextInToast("Pool added"); + }); + + await commonSteps.goToMinersPage(); + + let minerIp: string; + let minerStatus: string; + + await test.step("Open pool editor for first miner", async () => { + minerIp = await minersPage.getMinerIpAddressByIndex(0); + minerStatus = await minersPage.getMinerStatus(minerIp); + await minersPage.clickMinerThreeDotsButton(minerIp); + await minersPage.clickEditMiningPoolButton(); + await loginModal.loginAsAdmin(); + }); + + await test.step("Remove all existing pools from miner", async () => { + await editPoolPage.removeAllPools(); + }); + + await test.step("Add first pool to the miner", async () => { + await editPoolPage.clickAddPoolButton(); + await editPoolPage.validateModalIsOpen(); + await editPoolPage.clickPoolRowByName(newPoolName1); + await editPoolPage.clickSavePoolChoice(); + await editPoolPage.validateModalIsClosed(); + await editPoolPage.validatePoolCount(1); + }); + + await test.step("Add another pool to the miner", async () => { + await editPoolPage.clickAddAnotherPoolButton(); + await editPoolPage.clickAddNewPool(); + await editPoolPage.validateModalIsOpen(); + await newPoolModal.inputPoolName(newPoolName2); + await newPoolModal.inputPoolUrl(validPoolUrl); + await newPoolModal.inputPoolUsername(newPoolUsername2); + await newPoolModal.clickTestConnection(); + await newPoolModal.validateConnectionSuccessful(); + await newPoolModal.clickSaveNewPool(); + await editPoolPage.validateModalIsClosed(); + }); + + await test.step("Validate pool order", async () => { + await editPoolPage.validatePoolCount(2); + await editPoolPage.validatePoolByIndex(0, newPoolName1, validPoolUrl); + await editPoolPage.validatePoolByIndex(1, newPoolName2, validPoolUrl); + }); + + await test.step("Reorder mining pools", async () => { + await editPoolPage.reorderPoolByDragging(1, 0); + }); + + await test.step("Validate pool order after reorder", async () => { + await editPoolPage.validatePoolCount(2); + await editPoolPage.validatePoolByIndex(0, newPoolName2, validPoolUrl); + await editPoolPage.validatePoolByIndex(1, newPoolName1, validPoolUrl); + }); + + await test.step("Save pool changes", async () => { + await new Promise((resolve) => setTimeout(resolve, DEFAULT_INTERVAL)); + await editPoolPage.clickAssignToXMiners(1); + await editPoolPage.validateTextInToastGroup("Assigned pools"); + }); + + await test.step("Validate miner's status did not change", async () => { + await minersPage.validateMinerStatus(minerIp, minerStatus); + }); + + await test.step("Reopen miner and validate the pools have been saved successfully", async () => { + await minersPage.clickMinerThreeDotsButton(minerIp); + await minersPage.clickEditMiningPoolButton(); + await loginModal.loginAsAdmin(); + await editPoolPage.validatePoolCount(2); + expect(await editPoolPage.getPoolUrlByIndex(0)).toBe(validPoolUrl); + expect(await editPoolPage.getPoolUrlByIndex(1)).toBe(validPoolUrl); + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/addMinersValidation.spec.ts b/client/e2eTests/protoFleet/spec/addMinersValidation.spec.ts new file mode 100644 index 000000000..b88ec984b --- /dev/null +++ b/client/e2eTests/protoFleet/spec/addMinersValidation.spec.ts @@ -0,0 +1,181 @@ +/* eslint-disable playwright/expect-expect */ +import { expect, test } from "../fixtures/pageFixtures"; + +test.describe("Proto Fleet - Add Miners Validation", () => { + test.beforeEach(async ({ page, commonSteps }) => { + await page.goto("/"); + await commonSteps.loginAsAdmin(); + }); + + test("Shows validation error dialog for invalid IP addresses", async ({ minersPage, addMinersPage }) => { + await test.step("Navigate to add miners flow", async () => { + await minersPage.navigateToMinersPage(); + await minersPage.clickAddMinersButton(); + }); + + await test.step("Enter invalid IP addresses", async () => { + await addMinersPage.inputMinerIp("999.999.999.999, 256.1.1.1"); + await addMinersPage.clickFindMinersByIp(); + }); + + await test.step("Validate error dialog is shown with invalid entries", async () => { + await addMinersPage.validateValidationErrorDialogIsVisible(); + await addMinersPage.validateInvalidIpAddressesInDialog(["999.999.999.999", "256.1.1.1"]); + }); + }); + + test("Shows validation error dialog for invalid IP ranges", async ({ minersPage, addMinersPage }) => { + await test.step("Navigate to add miners flow", async () => { + await minersPage.navigateToMinersPage(); + await minersPage.clickAddMinersButton(); + }); + + await test.step("Enter invalid IP range (end before start)", async () => { + await addMinersPage.inputMinerIp("192.168.1.100-50"); + await addMinersPage.clickFindMinersByIp(); + }); + + await test.step("Validate error dialog is shown with invalid range", async () => { + await addMinersPage.validateValidationErrorDialogIsVisible(); + await addMinersPage.validateInvalidIpRangesInDialog(["192.168.1.100-50"]); + }); + }); + + test("Shows validation error dialog for invalid subnets", async ({ minersPage, addMinersPage }) => { + await test.step("Navigate to add miners flow", async () => { + await minersPage.navigateToMinersPage(); + await minersPage.clickAddMinersButton(); + }); + + await test.step("Enter invalid subnet (mask > 32)", async () => { + await addMinersPage.inputMinerIp("192.168.1.0/33"); + await addMinersPage.clickFindMinersByIp(); + }); + + await test.step("Validate error dialog is shown with invalid subnet", async () => { + await addMinersPage.validateValidationErrorDialogIsVisible(); + await addMinersPage.validateInvalidSubnetsInDialog(["192.168.1.0/33"]); + }); + }); + + test("Back to editing button closes dialog and returns to form", async ({ minersPage, addMinersPage }) => { + await test.step("Navigate to add miners flow", async () => { + await minersPage.navigateToMinersPage(); + await minersPage.clickAddMinersButton(); + }); + + await test.step("Enter mix of valid and invalid entries", async () => { + await addMinersPage.inputMinerIp("192.168.1.1, 999.999.999.999"); + await addMinersPage.clickFindMinersByIp(); + }); + + await test.step("Validate error dialog is shown", async () => { + await addMinersPage.validateValidationErrorDialogIsVisible(); + }); + + await test.step("Click back to editing", async () => { + await addMinersPage.clickBackToEditing(); + }); + + await test.step("Validate dialog is closed and form is still visible", async () => { + await addMinersPage.validateValidationErrorDialogIsClosed(); + // Verify the textarea is still accessible with the original value + const textarea = addMinersPage["page"].locator("#ipAddresses"); + await expect(textarea).toBeVisible(); + }); + + await test.step("Validate error message appears on textarea", async () => { + await addMinersPage.validateTextareaErrorContains("Check the format of the following and retry"); + await addMinersPage.validateTextareaErrorContains("999.999.999.999"); + }); + }); + + test("Continue anyway button proceeds with valid entries only", async ({ minersPage, addMinersPage, page }) => { + await test.step("Navigate to add miners flow", async () => { + await minersPage.navigateToMinersPage(); + await minersPage.clickAddMinersButton(); + }); + + await test.step("Enter mix of valid and invalid entries", async () => { + await addMinersPage.inputMinerIp("192.168.1.1, 999.999.999.999"); + await addMinersPage.clickFindMinersByIp(); + }); + + await test.step("Validate error dialog is shown", async () => { + await addMinersPage.validateValidationErrorDialogIsVisible(); + }); + + await test.step("Click continue anyway", async () => { + await addMinersPage.clickContinueAnyway(); + }); + + await test.step("Validate dialog is closed and discovery proceeds", async () => { + await addMinersPage.validateValidationErrorDialogIsClosed(); + // The pairing step should now be active (either loading or showing results) + const findingMinersTitle = page.getByText("Finding miners on your network"); + const foundMinersSection = page.getByText(/\d+ miners found/); + const noMinersFound = page.getByText(/No miners found/); + + // Wait for either the loading state, results, or no miners found + await expect(findingMinersTitle.or(foundMinersSection).or(noMinersFound)).toBeVisible({ timeout: 10000 }); + }); + }); + + test("Shows multiple error categories in dialog", async ({ minersPage, addMinersPage }) => { + await test.step("Navigate to add miners flow", async () => { + await minersPage.navigateToMinersPage(); + await minersPage.clickAddMinersButton(); + }); + + await test.step("Enter multiple types of invalid entries", async () => { + await addMinersPage.inputMinerIp("999.999.999.999, 192.168.1.100-50, 192.168.1.0/33"); + await addMinersPage.clickFindMinersByIp(); + }); + + await test.step("Validate all error categories are shown", async () => { + await addMinersPage.validateValidationErrorDialogIsVisible(); + await addMinersPage.validateInvalidIpAddressesInDialog(["999.999.999.999"]); + await addMinersPage.validateInvalidIpRangesInDialog(["192.168.1.100-50"]); + await addMinersPage.validateInvalidSubnetsInDialog(["192.168.1.0/33"]); + }); + }); + + test("Hides Continue anyway when all entries are invalid", async ({ minersPage, addMinersPage }) => { + await test.step("Navigate to add miners flow", async () => { + await minersPage.navigateToMinersPage(); + await minersPage.clickAddMinersButton(); + }); + + await test.step("Enter only invalid entries", async () => { + await addMinersPage.inputMinerIp("999.999.999.999, 256.1.1.1"); + await addMinersPage.clickFindMinersByIp(); + }); + + await test.step("Validate dialog shows only Back to editing button", async () => { + await addMinersPage.validateValidationErrorDialogIsVisible(); + await addMinersPage.validateContinueAnywayButtonNotVisible(); + }); + + await test.step("Back to editing works correctly", async () => { + await addMinersPage.clickBackToEditing(); + await addMinersPage.validateValidationErrorDialogIsClosed(); + }); + }); + + test("Shows Continue anyway when mix of valid and invalid entries", async ({ minersPage, addMinersPage }) => { + await test.step("Navigate to add miners flow", async () => { + await minersPage.navigateToMinersPage(); + await minersPage.clickAddMinersButton(); + }); + + await test.step("Enter mix of valid and invalid entries", async () => { + await addMinersPage.inputMinerIp("192.168.1.1, 999.999.999.999"); + await addMinersPage.clickFindMinersByIp(); + }); + + await test.step("Validate dialog shows both buttons", async () => { + await addMinersPage.validateValidationErrorDialogIsVisible(); + await addMinersPage.validateContinueAnywayButtonVisible(); + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/apiKeysSettings.spec.ts b/client/e2eTests/protoFleet/spec/apiKeysSettings.spec.ts new file mode 100644 index 000000000..2b2e22f00 --- /dev/null +++ b/client/e2eTests/protoFleet/spec/apiKeysSettings.spec.ts @@ -0,0 +1,110 @@ +/* eslint-disable playwright/expect-expect */ +import { testConfig } from "../config/test.config"; +import { test } from "../fixtures/pageFixtures"; +import { CommonSteps } from "../helpers/commonSteps"; +import { generateRandomText } from "../helpers/testDataHelper"; +import { AuthPage } from "../pages/auth"; +import { MinersPage } from "../pages/miners"; +import { SettingsApiKeysPage } from "../pages/settingsApiKeys"; + +const API_KEY_PREFIX = "e2e_api_key"; + +test.describe("Proto Fleet - API Keys", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test.afterEach("CLEANUP: Revoke any API keys created during tests", async ({ browser }, testInfo) => { + const isMobile = testInfo.project.use?.isMobile ?? false; + const viewport = testInfo.project.use?.viewport; + const context = await browser.newContext({ baseURL: testConfig.baseUrl, viewport }); + + try { + const page = await context.newPage(); + await page.goto("/"); + + const authPage = new AuthPage(page, isMobile); + const minersPage = new MinersPage(page, isMobile); + const settingsApiKeysPage = new SettingsApiKeysPage(page, isMobile); + const commonSteps = new CommonSteps(authPage, minersPage); + + await commonSteps.loginAsAdmin(); + await settingsApiKeysPage.navigateToApiKeysSettings(); + await settingsApiKeysPage.deleteApiKeysByPrefix(API_KEY_PREFIX); + } finally { + await context.close(); + } + }); + + test("Create and revoke API key", async ({ commonSteps, settingsApiKeysPage }) => { + const apiKeyName = generateRandomText(API_KEY_PREFIX); + + await test.step("Log in as admin", async () => { + await commonSteps.loginAsAdmin(); + }); + + await test.step("Navigate to API Keys settings", async () => { + await settingsApiKeysPage.navigateToApiKeysSettings(); + await settingsApiKeysPage.validateApiKeysPageOpened(); + }); + + await test.step("Create a new API key without expiration", async () => { + await settingsApiKeysPage.clickCreateApiKey(); + await settingsApiKeysPage.inputApiKeyName(apiKeyName); + await settingsApiKeysPage.clickCreateInModal(); + await settingsApiKeysPage.validateApiKeyCreated(); + await settingsApiKeysPage.clickDone(); + }); + + await test.step("Validate the API key appears in the list", async () => { + await settingsApiKeysPage.validateApiKeyVisible(apiKeyName); + await settingsApiKeysPage.validateApiKeyHasNoExpiration(apiKeyName); + }); + + await test.step("Revoke the API key", async () => { + await settingsApiKeysPage.clickRevokeApiKey(apiKeyName); + await settingsApiKeysPage.confirmRevokeApiKey(); + await settingsApiKeysPage.validateApiKeyNotVisible(apiKeyName); + }); + }); + + test("Expiration validation", async ({ commonSteps, settingsApiKeysPage }) => { + const apiKeyName = generateRandomText(API_KEY_PREFIX); + const today = new Date(); + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 2); + + await test.step("Log in as admin", async () => { + await commonSteps.loginAsAdmin(); + }); + + await test.step("Navigate to API Keys settings", async () => { + await settingsApiKeysPage.navigateToApiKeysSettings(); + await settingsApiKeysPage.validateApiKeysPageOpened(); + }); + + await test.step("Validate key name is required", async () => { + await settingsApiKeysPage.clickCreateApiKey(); + await settingsApiKeysPage.clickCreateInModal(); + await settingsApiKeysPage.validateApiKeyNameRequired(); + }); + + await test.step("Validate today cannot be selected as an expiration date", async () => { + await settingsApiKeysPage.openExpirationDatePicker(); + await settingsApiKeysPage.validateExpirationDayDisabled(today.getDate()); + }); + + await test.step("Create a new API key with a future expiration date", async () => { + await settingsApiKeysPage.selectExpirationDate(futureDate); + await settingsApiKeysPage.inputApiKeyName(apiKeyName); + await settingsApiKeysPage.clickCreateInModal(); + await settingsApiKeysPage.validateApiKeyCreated(); + await settingsApiKeysPage.clickDone(); + }); + + await test.step("Validate the API key expiration is saved", async () => { + await settingsApiKeysPage.validateApiKeyVisible(apiKeyName); + await settingsApiKeysPage.validateApiKeyHasExpiration(apiKeyName); + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/auth.spec.ts b/client/e2eTests/protoFleet/spec/auth.spec.ts new file mode 100644 index 000000000..178b66853 --- /dev/null +++ b/client/e2eTests/protoFleet/spec/auth.spec.ts @@ -0,0 +1,24 @@ +/* eslint-disable playwright/expect-expect */ +import { testConfig } from "../config/test.config"; +import { test } from "../fixtures/pageFixtures"; + +test.describe("Proto Fleet - Authentication", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test("Sign in with admin", async ({ authPage, settingsPage, settingsTeamPage }) => { + await test.step("Log in as admin user", async () => { + await authPage.inputUsername(testConfig.users.admin.username); + await authPage.inputPassword(testConfig.users.admin.password); + await authPage.clickLogin(); + await authPage.validateLoggedIn(); + }); + + await test.step("Navigate to Team Settings and validate admin access", async () => { + await settingsPage.navigateToTeamSettings(); + await settingsTeamPage.validateTeamSettingsPageOpened(); + await settingsTeamPage.validateIsAdmin(); + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/firmware.spec.ts b/client/e2eTests/protoFleet/spec/firmware.spec.ts new file mode 100644 index 000000000..359599b83 --- /dev/null +++ b/client/e2eTests/protoFleet/spec/firmware.spec.ts @@ -0,0 +1,128 @@ +import { testConfig } from "../config/test.config"; +import { test } from "../fixtures/pageFixtures"; +import { CommonSteps } from "../helpers/commonSteps"; +import { AuthPage } from "../pages/auth"; +import { MinersPage } from "../pages/miners"; +import { SettingsFirmwarePage } from "../pages/settingsFirmware"; + +async function cleanupUpdatedRigMiner(minersPage: MinersPage, rigMinerIp: string) { + const currentStatus = (await minersPage.getMinerStatus(rigMinerIp)).trim(); + + if (currentStatus === "Updating firmware") { + await minersPage.validateMinerStatusSettled(rigMinerIp, "Reboot required", testConfig.testTimeout); + } + + const rebootRequiredStatus = (await minersPage.getMinerStatus(rigMinerIp)).trim(); + if (rebootRequiredStatus === "Reboot required") { + await minersPage.clickMinerThreeDotsButton(rigMinerIp); + await minersPage.clickRebootButton(); + await minersPage.clickRebootConfirm(); + await minersPage.validateMinerStatusSettled(rigMinerIp, "Hashing"); + } +} + +test.describe("Firmware", () => { + let updatedRigMinerIp = ""; + + // eslint-disable-next-line playwright/no-skipped-test + test.skip(testConfig.target === "real", "Firmware update E2E is only supported against the fake proto rig setup."); + + test.beforeEach(async ({ page, commonSteps }) => { + updatedRigMinerIp = ""; + await page.goto("/"); + await commonSteps.loginAsAdmin(); + }); + + test.afterEach( + "CLEANUP: Reboot updated rig miners and delete uploaded firmware files", + async ({ browser }, testInfo) => { + const isMobile = testInfo.project.use?.isMobile ?? false; + const context = await browser.newContext({ + baseURL: testConfig.baseUrl, + viewport: testInfo.project.use?.viewport, + }); + + try { + const page = await context.newPage(); + await page.goto("/"); + + const authPage = new AuthPage(page, isMobile); + const minersPage = new MinersPage(page, isMobile); + const settingsFirmwarePage = new SettingsFirmwarePage(page, isMobile); + const commonSteps = new CommonSteps(authPage, minersPage); + + await commonSteps.loginAsAdmin(); + + await minersPage.navigateToMinersPage(); + await minersPage.waitForMinersListToLoad(); + await minersPage.filterRigMiners(); + if (updatedRigMinerIp) { + await cleanupUpdatedRigMiner(minersPage, updatedRigMinerIp); + } + + await settingsFirmwarePage.navigateToFirmwareSettings(); + await settingsFirmwarePage.validateFirmwarePageOpened(); + await settingsFirmwarePage.deleteAllFirmwareFilesIfAny(); + } finally { + updatedRigMinerIp = ""; + await context.close(); + } + }, + ); + + test("Upload firmware and update a rig miner", async ({ minersPage, settingsFirmwarePage }) => { + test.setTimeout(testConfig.testTimeout * 4); + + const firmwareFileName = `firmware-${Date.now()}.swu`; + const firmwareFileContents = `fake firmware payload ${Date.now()}`; + const firmwareStatusTimeout = testConfig.testTimeout; + + await test.step("Upload a firmware payload in Settings", async () => { + await settingsFirmwarePage.navigateToFirmwareSettings(); + await settingsFirmwarePage.validateFirmwarePageOpened(); + await settingsFirmwarePage.deleteAllFirmwareFilesIfAny(); + + await settingsFirmwarePage.clickUploadFirmware(); + await settingsFirmwarePage.uploadFirmwareFile(firmwareFileName, firmwareFileContents); + await settingsFirmwarePage.clickDoneInUploadDialog(); + await settingsFirmwarePage.validateTextInToast("Firmware file uploaded successfully"); + await settingsFirmwarePage.validateFirmwareFileVisible(firmwareFileName); + }); + + let rigMinerIp = ""; + + await test.step("Pick a hashing rig miner", async () => { + await minersPage.navigateToMinersPage(); + await minersPage.waitForMinersListToLoad(); + await minersPage.filterRigMiners(); + await test.expect + .poll(async () => await minersPage.hasAnyMinerWithStatus("Hashing"), { + timeout: testConfig.testTimeout, + }) + .toBe(true); + + rigMinerIp = await minersPage.getMinerIpAddressByStatus("Hashing"); + updatedRigMinerIp = rigMinerIp; + }); + + await test.step("Start the firmware update from the miner actions menu", async () => { + await minersPage.clickMinerThreeDotsButton(rigMinerIp); + await minersPage.clickUpdateFirmwareButton(); + await minersPage.validateFirmwareUpdateModalOpened(); + await minersPage.selectExistingFirmwareFile(firmwareFileName); + await minersPage.clickContinueInFirmwareUpdateModal(); + }); + + await test.step("Validate the miner transitions through firmware update states", async () => { + await minersPage.validateMinerStatusSettled(rigMinerIp, "Updating firmware", firmwareStatusTimeout); + await minersPage.validateMinerStatusSettled(rigMinerIp, "Reboot required", firmwareStatusTimeout); + }); + + await test.step("Reboot the miner and validate it returns to hashing", async () => { + await minersPage.clickMinerThreeDotsButton(rigMinerIp); + await minersPage.clickRebootButton(); + await minersPage.clickRebootConfirm(); + await minersPage.validateMinerStatusSettled(rigMinerIp, "Hashing"); + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/generalSettings.spec.ts b/client/e2eTests/protoFleet/spec/generalSettings.spec.ts new file mode 100644 index 000000000..bb3607589 --- /dev/null +++ b/client/e2eTests/protoFleet/spec/generalSettings.spec.ts @@ -0,0 +1,79 @@ +/* eslint-disable playwright/expect-expect */ +import { testConfig } from "../config/test.config"; +import { test } from "../fixtures/pageFixtures"; +import { CommonSteps } from "../helpers/commonSteps"; +import { AuthPage } from "../pages/auth"; +import { MinersPage } from "../pages/miners"; +import { SettingsPage } from "../pages/settings"; + +test.describe("General Settings", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test.afterAll("CLEANUP: Ensure temperature is Celsius", async ({ browser }, testInfo) => { + const isMobile = testInfo.project.use?.isMobile ?? false; + const context = await browser.newContext({ baseURL: testConfig.baseUrl }); + const page = await context.newPage(); + await page.goto("/"); + + try { + const authPage = new AuthPage(page, isMobile); + const minersPage = new MinersPage(page, isMobile); + const settingsPage = new SettingsPage(page, isMobile); + const commonSteps = new CommonSteps(authPage, minersPage); + + await commonSteps.loginAsAdmin(); + await authPage.navigateToSettingsPage(); + + const currentTemperature = await settingsPage.getCurrentTemperatureFormat(); + + if (currentTemperature !== "Celsius") { + await settingsPage.clickTemperatureButton(); + await settingsPage.selectCelsius(); + await settingsPage.clickDoneButton(); + await settingsPage.validateTemperatureFormatCelsius(); + } + } finally { + await context.close(); + } + }); + + test("Set temperature format", async ({ authPage, settingsPage, minersPage, commonSteps }) => { + await commonSteps.loginAsAdmin(); + + await test.step("Navigate to general settings", async () => { + await authPage.navigateToSettingsPage(); + }); + + await test.step("Set temperature to Fahrenheit", async () => { + await settingsPage.clickTemperatureButton(); + await settingsPage.selectFahrenheit(); + await settingsPage.clickDoneButton(); + await settingsPage.validateTemperatureFormatFahrenheit(); + }); + + await commonSteps.goToMinersPage(); + + await test.step("Verify miner temperature is displayed in Fahrenheit", async () => { + await minersPage.validateTemperatureUnitFahrenheit(); + }); + + await test.step("Navigate back to settings", async () => { + await authPage.navigateToSettingsPage(); + }); + + await test.step("Change temperature format to Celsius", async () => { + await settingsPage.clickTemperatureButton(); + await settingsPage.selectCelsius(); + await settingsPage.clickDoneButton(); + await settingsPage.validateTemperatureFormatCelsius(); + }); + + await commonSteps.goToMinersPage(); + + await test.step("Verify miner temperature is displayed in Celsius", async () => { + await minersPage.validateTemperatureUnitCelsius(); + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/groups.spec.ts b/client/e2eTests/protoFleet/spec/groups.spec.ts new file mode 100644 index 000000000..525f794e4 --- /dev/null +++ b/client/e2eTests/protoFleet/spec/groups.spec.ts @@ -0,0 +1,345 @@ +/* eslint-disable playwright/expect-expect */ +import { testConfig } from "../config/test.config"; +import { test } from "../fixtures/pageFixtures"; +import { CommonSteps } from "../helpers/commonSteps"; +import { PROTO_RIG_MODEL } from "../helpers/minerModels"; +import { generateRandomText } from "../helpers/testDataHelper"; +import { AuthPage } from "../pages/auth"; +import { GroupsPage } from "../pages/groups"; +import { MinersPage } from "../pages/miners"; + +test.describe("Groups", () => { + test.beforeEach(async ({ page, groupsPage, commonSteps }) => { + await page.goto("/"); + await commonSteps.loginAsAdmin(); + await groupsPage.navigateToGroupsPage(); + await cleanupAllGroups(groupsPage); + }); + + test.afterAll("CLEANUP: Delete all groups", async ({ browser }, testInfo) => { + const isMobile = testInfo.project.use?.isMobile ?? false; + const context = await browser.newContext({ baseURL: testConfig.baseUrl }); + try { + const page = await context.newPage(); + await page.goto("/"); + + const authPage = new AuthPage(page, isMobile); + const minersPage = new MinersPage(page, isMobile); + const groupsPage = new GroupsPage(page, isMobile); + const commonSteps = new CommonSteps(authPage, minersPage); + + await commonSteps.loginAsAdmin(); + await groupsPage.navigateToGroupsPage(); + await cleanupAllGroups(groupsPage); + } finally { + await context.close(); + } + }); + + async function cleanupAllGroups(groupsPage: GroupsPage) { + const existingGroupNames = await groupsPage.listSavedGroupNames(); + + if (existingGroupNames.length === 0) { + return; + } + + const automationGroups = existingGroupNames.filter((groupName) => groupName.startsWith("automation")); + + for (const groupName of automationGroups) { + await groupsPage.openSavedGroup(groupName); + await groupsPage.clickDeleteGroupInModal(); + await groupsPage.clickDeleteConfirm(); + await groupsPage.validateSavedGroupNotVisible(groupName); + } + } + + test("Create, edit, and delete groups", async ({ groupsPage }) => { + const groupName = generateRandomText("automation"); + const editedGroupName = generateRandomText("automation-edited"); + + await test.step("Create new group with all miners", async () => { + await groupsPage.clickAddGroupButton(); + await groupsPage.inputGroupName(groupName); + + await groupsPage.waitForModalListToLoad(); + const allMinersCount = await groupsPage.getModalListRowCount(); + + await groupsPage.clickSelectAllCheckboxInModal(); + await groupsPage.clickSaveInModal(); + + await groupsPage.validateTextInToast(`Group "${groupName}" created`); + await groupsPage.validateSavedGroupVisible(groupName); + await groupsPage.validateSavedGroupMinerCount(groupName, allMinersCount); + }); + + await test.step("Edit group to only rig miners", async () => { + await groupsPage.openSavedGroup(groupName); + await groupsPage.waitForModalListToLoad(); + + await groupsPage.inputGroupName(editedGroupName); + + // clear previous selection + await groupsPage.clickSelectAllCheckboxInModal(); + + await groupsPage.filterModalType(PROTO_RIG_MODEL); + await groupsPage.waitForModalListToLoad(); + + await groupsPage.clickSelectAllCheckboxInModal(); + const rigMinersCount = await groupsPage.getModalListRowCount(); + + await groupsPage.clickSaveInModal(); + + await groupsPage.validateTextInToast(`Group "${editedGroupName}" updated`); + await groupsPage.validateSavedGroupVisible(editedGroupName); + await groupsPage.validateSavedGroupMinerCount(editedGroupName, rigMinersCount); + }); + + await test.step("Delete group", async () => { + await groupsPage.openSavedGroup(editedGroupName); + await groupsPage.clickDeleteGroupInModal(); + await groupsPage.validateTitle(`Delete "${editedGroupName}"?`); + await groupsPage.clickDeleteConfirm(); + + await groupsPage.validateTextInToast(`Group "${editedGroupName}" deleted`); + await groupsPage.validateSavedGroupNotVisible(editedGroupName); + }); + }); + + test("Validate groups association to miners", async ({ groupsPage }) => { + const group1Name = generateRandomText("automation1"); + const group2Name = generateRandomText("automation2"); + const group3Name = generateRandomText("automation3"); + const minerIps: string[] = []; + + await test.step("Capture 5 clean miners with no existing groups", async () => { + await groupsPage.clickAddGroupButton(); + await groupsPage.waitForModalListToLoad(); + minerIps.push(...(await groupsPage.getUngroupedMinerIps(5))); + test.expect(minerIps).toHaveLength(5); + await groupsPage.closeModal(); + }); + + await test.step("Create group1 with miners 0-2", async () => { + await groupsPage.clickAddGroupButton(); + await groupsPage.inputGroupName(group1Name); + await groupsPage.waitForModalListToLoad(); + for (const ip of minerIps.slice(0, 3)) { + await groupsPage.selectMinerByIp(ip); + } + await groupsPage.clickSaveInModal(); + await groupsPage.validateTextInToast(`Group "${group1Name}" created`); + await groupsPage.validateSavedGroupVisible(group1Name); + await groupsPage.validateSavedGroupMinerCount(group1Name, 3); + }); + + await test.step("Validate specific miners have group1 in group column", async () => { + await groupsPage.clickAddGroupButton(); + await groupsPage.waitForModalListToLoad(); + await groupsPage.validateMinerGroupsByIp(minerIps[0], group1Name); + await groupsPage.validateMinerGroupsByIp(minerIps[1], group1Name); + await groupsPage.validateMinerGroupsByIp(minerIps[2], group1Name); + await groupsPage.closeModal(); + }); + + await test.step("Create group2 with miners 1-3", async () => { + await groupsPage.clickAddGroupButton(); + await groupsPage.inputGroupName(group2Name); + await groupsPage.waitForModalListToLoad(); + for (const ip of minerIps.slice(1, 4)) { + await groupsPage.selectMinerByIp(ip); + } + await groupsPage.clickSaveInModal(); + await groupsPage.validateTextInToast(`Group "${group2Name}" created`); + await groupsPage.validateSavedGroupVisible(group2Name); + await groupsPage.validateSavedGroupMinerCount(group2Name, 3); + }); + + await test.step("Validate specific miners have group1 & group2 in group column", async () => { + await groupsPage.clickAddGroupButton(); + await groupsPage.waitForModalListToLoad(); + await groupsPage.validateMinerGroupsByIp(minerIps[0], group1Name); + await groupsPage.validateMinerGroupsByIp(minerIps[1], `${group1Name}, ${group2Name}`); + await groupsPage.validateMinerGroupsByIp(minerIps[2], `${group1Name}, ${group2Name}`); + await groupsPage.validateMinerGroupsByIp(minerIps[3], group2Name); + await groupsPage.closeModal(); + }); + + await test.step("Create group3 with miners 2-4", async () => { + await groupsPage.clickAddGroupButton(); + await groupsPage.inputGroupName(group3Name); + await groupsPage.waitForModalListToLoad(); + for (const ip of minerIps.slice(2, 5)) { + await groupsPage.selectMinerByIp(ip); + } + await groupsPage.clickSaveInModal(); + await groupsPage.validateTextInToast(`Group "${group3Name}" created`); + await groupsPage.validateSavedGroupVisible(group3Name); + await groupsPage.validateSavedGroupMinerCount(group3Name, 3); + }); + + await test.step("Validate specific miners have group1, group2 & group3 in group column", async () => { + await groupsPage.clickAddGroupButton(); + await groupsPage.waitForModalListToLoad(); + await groupsPage.validateMinerGroupsByIp(minerIps[0], group1Name); + await groupsPage.validateMinerGroupsByIp(minerIps[1], `${group1Name}, ${group2Name}`); + await groupsPage.validateMinerGroupsByIp(minerIps[2], `${group1Name}, ${group2Name}, ${group3Name}`); + await groupsPage.validateMinerGroupsByIp(minerIps[3], `${group2Name}, ${group3Name}`); + await groupsPage.validateMinerGroupsByIp(minerIps[4], group3Name); + await groupsPage.closeModal(); + }); + + await test.step("Validate each group filter shows correct miners", async () => { + await groupsPage.clickAddGroupButton(); + await groupsPage.waitForModalListToLoad(); + + await groupsPage.filterModalGroup(group1Name); + await groupsPage.waitForModalListToLoad(); + await groupsPage.validateOnlyTheseIpsVisibleInModal([minerIps[0], minerIps[1], minerIps[2]]); + + await groupsPage.filterModalGroup(group2Name); + await groupsPage.waitForModalListToLoad(); + await groupsPage.validateOnlyTheseIpsVisibleInModal([minerIps[1], minerIps[2], minerIps[3]]); + + await groupsPage.filterModalGroup(group3Name); + await groupsPage.waitForModalListToLoad(); + await groupsPage.validateOnlyTheseIpsVisibleInModal([minerIps[2], minerIps[3], minerIps[4]]); + + await groupsPage.closeModal(); + }); + + await test.step("Delete group2", async () => { + await groupsPage.openSavedGroup(group2Name); + await groupsPage.clickDeleteGroupInModal(); + await groupsPage.validateTitle(`Delete "${group2Name}"?`); + await groupsPage.clickDeleteConfirm(); + await groupsPage.validateTextInToast(`Group "${group2Name}" deleted`); + await groupsPage.validateSavedGroupNotVisible(group2Name); + }); + + await test.step("Validate specific miners have group1, group3 in group column", async () => { + await groupsPage.clickAddGroupButton(); + await groupsPage.waitForModalListToLoad(); + await groupsPage.validateMinerGroupsByIp(minerIps[0], group1Name); + await groupsPage.validateMinerGroupsByIp(minerIps[1], group1Name); + await groupsPage.validateMinerGroupsByIp(minerIps[2], `${group1Name}, ${group3Name}`); + await groupsPage.validateMinerGroupsByIp(minerIps[3], group3Name); + await groupsPage.validateMinerGroupsByIp(minerIps[4], group3Name); + await groupsPage.closeModal(); + }); + }); + + test("Cannot create group with no title or miners or with duplicate name", async ({ groupsPage }) => { + const groupName = generateRandomText("automation1"); + const secondGroupName = generateRandomText("automation2"); + + await test.step("Try to create a group without a title", async () => { + await groupsPage.clickAddGroupButton(); + await groupsPage.clickSaveInModal(); + }); + + await test.step("Validate missing name error", async () => { + await groupsPage.validateErrorMessage("Group name is required"); + }); + + await test.step("Try to create a group without any miner", async () => { + await groupsPage.inputGroupName(groupName); + await groupsPage.clickSaveInModal(); + }); + + await test.step("Validate no miners selected error", async () => { + await groupsPage.validateErrorMessage("Select at least one miner"); + }); + + await test.step("Finish creating a valid group", async () => { + await groupsPage.clickSelectAllCheckboxInModal(); + await groupsPage.clickSaveInModal(); + await groupsPage.validateTextInToast(`Group "${groupName}" created`); + await groupsPage.validateSavedGroupVisible(groupName); + }); + + await test.step("Try to create a group with an existing group name", async () => { + await groupsPage.clickAddGroupButton(); + await groupsPage.inputGroupName(groupName); + await groupsPage.clickSelectAllCheckboxInModal(); + await groupsPage.clickSaveInModal(); + }); + + await test.step("Validate duplicate group name error", async () => { + await groupsPage.validateErrorMessage("A group with this name already exists"); + }); + + await test.step("Finish creating a second valid group", async () => { + await groupsPage.inputGroupName(secondGroupName); + await groupsPage.clickSaveInModal(); + await groupsPage.validateTextInToast(`Group "${secondGroupName}" created`); + await groupsPage.validateSavedGroupVisible(groupName); + await groupsPage.validateSavedGroupVisible(secondGroupName); + }); + }); + + test("Create a group with all miners from Miners page and reboot group from Groups page", async ({ + minersPage, + groupsPage, + commonSteps, + }) => { + const groupName = generateRandomText("automation"); + let minerCount: number; + + await test.step("Go to miners page", async () => { + await commonSteps.goToMinersPage(); + }); + + await test.step("Select all miners and create group", async () => { + minerCount = await minersPage.getMinersCount(); + await minersPage.clickSelectAllCheckbox(); + await minersPage.clickActionsMenuButton(); + await minersPage.clickAddToGroupButton(); + await minersPage.inputNewGroupName(groupName); + await minersPage.clickSaveInModal(); + }); + + await test.step("Validate group creation success", async () => { + await minersPage.validateTextInToast(`Added ${minerCount} miners to group`); + }); + + await test.step("Reload page (workaround for DASH-1435)", async () => { + await minersPage.reloadPage(); + await minersPage.waitForMinersTitle(); + await minersPage.waitForMinersListToLoad(); + }); + + await test.step("Validate group name in group column for all miners", async () => { + const currentMinerCount = await minersPage.getMinersCount(); + for (let i = 0; i < currentMinerCount; i++) { + const minerIp = await minersPage.getMinerIpAddressByIndex(i); + await minersPage.validateMinerGroupName(minerIp, groupName); + } + }); + + await test.step("Navigate to groups page and validate group", async () => { + await groupsPage.navigateToGroupsPage(); + await groupsPage.validateSavedGroupVisible(groupName); + await groupsPage.validateSavedGroupMinerCount(groupName, minerCount); + }); + + await test.step("Reboot group from Groups page", async () => { + await groupsPage.clickGroupActionsButton(groupName); + await groupsPage.clickRebootGroupButton(); + await groupsPage.validateRebootConfirmationModal(minerCount); + await groupsPage.clickRebootConfirm(); + }); + + await test.step("Validate reboot success", async () => { + await groupsPage.validateTextInToastGroup(`Rebooted ${minerCount} out of ${minerCount} miners`); + }); + + await test.step("Navigate to miners page and validate rebooting status", async () => { + await commonSteps.goToMinersPage(); + await minersPage.validateAllMinersStatus("Rebooting"); + }); + + await test.step("Wait for Hashing status (reduce risk of causing issues to the next test)", async () => { + await minersPage.validateNoMinerWithStatus("Rebooting"); + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/minerIssues.spec.ts b/client/e2eTests/protoFleet/spec/minerIssues.spec.ts new file mode 100644 index 000000000..d6a9580bf --- /dev/null +++ b/client/e2eTests/protoFleet/spec/minerIssues.spec.ts @@ -0,0 +1,219 @@ +/* eslint-disable playwright/expect-expect */ +import { test } from "../fixtures/pageFixtures"; +import { IssueIcon } from "../helpers/testDataHelper"; + +test.describe("Miner Issues Tests", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test("mock ErrorQueryService with custom errors", async ({ page, minersPage, commonSteps }) => { + const errorControlBoard = "COMPONENT_TYPE_CONTROL_BOARD"; + const errorHashBoard = "COMPONENT_TYPE_HASH_BOARD"; + const errorPsu = "COMPONENT_TYPE_PSU"; + const errorFan = "COMPONENT_TYPE_FAN"; + const date = "2026-01-01T12:00:00.203124Z"; + let testMiners: Array<{ deviceIdentifier: string; ipAddress: string; name: string }> = []; + + await commonSteps.loginAsAdmin(); + + await test.step("Capture miner data from ListMinerStateSnapshots", async () => { + const expectedMinerCount = 5; + const responsePromise = page.waitForResponse(async (response) => { + if (!response.url().includes("ListMinerStateSnapshots")) return false; + const data = await response.json(); + return Array.isArray(data.miners) && data.miners.length >= expectedMinerCount; + }); + await commonSteps.goToMinersPage(); + const response = await responsePromise; + const responseData = await response.json(); + + testMiners = responseData.miners.map((miner: { deviceIdentifier: string; ipAddress: string; name: string }) => ({ + deviceIdentifier: miner.deviceIdentifier, + ipAddress: miner.ipAddress, + name: miner.name, + })); + }); + + await test.step("Setup error mock for ErrorQueryService", async () => { + const mockErrorData = { + devices: { + items: [ + { + deviceIdentifier: testMiners[0].deviceIdentifier, + errors: [ + { + errorId: "test-error-1", + summary: errorControlBoard, + lastSeenAt: date, + deviceIdentifier: testMiners[0].deviceIdentifier, + componentType: errorControlBoard, + }, + ], + }, + { + deviceIdentifier: testMiners[1].deviceIdentifier, + errors: [ + { + errorId: "test-error-2", + summary: errorHashBoard, + lastSeenAt: date, + deviceIdentifier: testMiners[1].deviceIdentifier, + componentType: errorHashBoard, + }, + ], + }, + { + deviceIdentifier: testMiners[2].deviceIdentifier, + errors: [ + { + errorId: "test-error-3", + summary: errorPsu, + lastSeenAt: date, + deviceIdentifier: testMiners[2].deviceIdentifier, + componentType: errorPsu, + }, + ], + }, + { + deviceIdentifier: testMiners[3].deviceIdentifier, + errors: [ + { + errorId: "test-error-4", + summary: errorFan, + lastSeenAt: date, + deviceIdentifier: testMiners[3].deviceIdentifier, + componentType: errorFan, + }, + ], + }, + { + deviceIdentifier: testMiners[4].deviceIdentifier, + errors: [ + { + errorId: "test-error-5a", + summary: errorControlBoard, + lastSeenAt: date, + deviceIdentifier: testMiners[4].deviceIdentifier, + componentType: errorControlBoard, + }, + { + errorId: "test-error-5b", + summary: errorHashBoard, + lastSeenAt: date, + deviceIdentifier: testMiners[4].deviceIdentifier, + componentType: errorHashBoard, + }, + { + errorId: "test-error-5c", + summary: errorPsu, + lastSeenAt: date, + deviceIdentifier: testMiners[4].deviceIdentifier, + componentType: errorPsu, + }, + { + errorId: "test-error-5d", + summary: errorFan, + lastSeenAt: date, + deviceIdentifier: testMiners[4].deviceIdentifier, + componentType: errorFan, + }, + ], + }, + ], + }, + }; + + await page.route(/ErrorQueryService\/Query/, async (route) => { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(mockErrorData), + }); + }); + + await minersPage.reloadPage(); + await minersPage.validateMinersPageOpened(); + }); + + await test.step("Validate first miner icons and status modal - CONTROL BOARD failure", async () => { + const ip = testMiners[0].ipAddress; + const name = testMiners[0].name; + await minersPage.validateMinerIcon(ip, "issues", IssueIcon.CONTROL_BOARD); + await minersPage.validateMinerIcon(ip, "name", IssueIcon.GENERAL_ALERT); + await minersPage.clickMinerElementAndExpectModal(ip, "issues", name); + await minersPage.validateTitleInModal("Control board failure"); + await minersPage.validateErrorInModal(errorControlBoard, IssueIcon.CONTROL_BOARD); + await minersPage.clickCloseStatusModal(); + }); + + await test.step("Validate second miner icons and status modal - HASHBOARD failure", async () => { + const ip = testMiners[1].ipAddress; + const name = testMiners[1].name; + await minersPage.validateMinerIcon(ip, "issues", IssueIcon.HASH_BOARD); + await minersPage.validateMinerIcon(ip, "name", IssueIcon.GENERAL_ALERT); + await minersPage.clickMinerElementAndExpectModal(ip, "issues", name); + await minersPage.validateTitleInModal("Hashboard failure"); + await minersPage.validateErrorInModal(errorHashBoard, IssueIcon.HASH_BOARD); + await minersPage.clickCloseStatusModal(); + }); + + await test.step("Validate third miner icons and status modal - PSU failure", async () => { + const ip = testMiners[2].ipAddress; + const name = testMiners[2].name; + await minersPage.validateMinerIcon(ip, "issues", IssueIcon.PSU); + await minersPage.validateMinerIcon(ip, "name", IssueIcon.GENERAL_ALERT); + await minersPage.clickMinerElementAndExpectModal(ip, "issues", name); + await minersPage.validateTitleInModal("PSU failure"); + await minersPage.validateErrorInModal(errorPsu, IssueIcon.PSU); + await minersPage.clickCloseStatusModal(); + }); + + await test.step("Validate fourth miner icons and status modal - FAN failure", async () => { + const ip = testMiners[3].ipAddress; + const name = testMiners[3].name; + await minersPage.validateMinerIcon(ip, "issues", IssueIcon.FAN); + await minersPage.validateMinerIcon(ip, "name", IssueIcon.GENERAL_ALERT); + + await minersPage.clickMinerElementAndExpectModal(ip, "issues", name); + await minersPage.validateTitleInModal("Fan failure"); + await minersPage.validateErrorInModal(errorFan, IssueIcon.FAN); + await minersPage.clickCloseStatusModal(); + }); + + await test.step("Validate fifth miner icons and status modal - Multiple failures", async () => { + const ip = testMiners[4].ipAddress; + const name = testMiners[4].name; + await minersPage.validateMinerIcon(ip, "issues", IssueIcon.GENERAL_ALERT); + await minersPage.validateMinerIcon(ip, "name", IssueIcon.GENERAL_ALERT); + await minersPage.clickMinerElementAndExpectModal(ip, "issues", name); + await minersPage.validateTitleInModal("Multiple failures"); + await minersPage.validateErrorInModal(errorControlBoard, IssueIcon.CONTROL_BOARD); + await minersPage.validateErrorInModal(errorHashBoard, IssueIcon.HASH_BOARD); + await minersPage.validateErrorInModal(errorPsu, IssueIcon.PSU); + await minersPage.validateErrorInModal(errorFan, IssueIcon.FAN); + await minersPage.clickCloseStatusModal(); + }); + + const firstMinerIp = testMiners[0].ipAddress; + const firstMinerName = testMiners[0].name; + + await test.step("Validate modal can be opened from alert icon", async () => { + // From general alert icon + await minersPage.clickMinerElementAndExpectModal(firstMinerIp, "alert-icon", firstMinerName); + await minersPage.clickCloseStatusModal(); + }); + + await test.step("Validate modal can be opened from status column", async () => { + // From status column + await minersPage.clickMinerElementAndExpectModal(firstMinerIp, "status", firstMinerName); + await minersPage.clickCloseStatusModal(); + }); + + await test.step("Validate modal can be opened from issues column", async () => { + // From issues column + await minersPage.clickMinerElementAndExpectModal(firstMinerIp, "issues", firstMinerName); + await minersPage.clickCloseStatusModal(); + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/minersActions.spec.ts b/client/e2eTests/protoFleet/spec/minersActions.spec.ts new file mode 100644 index 000000000..f1b38f131 --- /dev/null +++ b/client/e2eTests/protoFleet/spec/minersActions.spec.ts @@ -0,0 +1,362 @@ +import { test } from "../fixtures/pageFixtures"; + +test.describe("Miners", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test("REBOOT a single miner", async ({ minersPage, page, commonSteps }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + const requestPromise = page.waitForRequest(/Reboot/); + const responsePromise = page.waitForResponse(/Reboot/); + + await test.step("Select first miner and reboot it", async () => { + let minerIp = await minersPage.getMinerIpAddressByIndex(0); + await minersPage.clickMinerThreeDotsButton(minerIp); + await minersPage.clickRebootButton(); + await minersPage.clickRebootConfirm(); + }); + + await test.step("Validate update process", async () => { + await minersPage.validateTextInToastGroup("Rebooting"); + await minersPage.validateTextInToastGroup("Rebooted"); + }); + + await test.step("Validate reboot API request", async () => { + const request = await requestPromise; + const response = await responsePromise; + const requestBody = request.postDataJSON(); + test.expect(request.method()).toBe("POST"); + test.expect(requestBody).toHaveProperty("deviceSelector"); + test.expect(requestBody.deviceSelector).toHaveProperty("includeDevices"); + test.expect(requestBody.deviceSelector.includeDevices).toHaveProperty("deviceIdentifiers"); + test.expect(requestBody.deviceSelector.includeDevices.deviceIdentifiers).toHaveLength(1); + test.expect(response.status()).toBe(200); + }); + }); + + test("REBOOT multiple miners", async ({ minersPage, page, commonSteps }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + const requestPromise = page.waitForRequest(/Reboot/); + const responsePromise = page.waitForResponse(/Reboot/); + + await test.step("Select multiple miners and reboot them", async () => { + let minerIp1 = await minersPage.getMinerIpAddressByIndex(0); + let minerIp2 = await minersPage.getMinerIpAddressByIndex(1); + let minerIp3 = await minersPage.getMinerIpAddressByIndex(2); + + await minersPage.clickMinerCheckbox(minerIp1); + await minersPage.validateActionBarMinerCount(1); + await minersPage.clickMinerCheckbox(minerIp2); + await minersPage.validateActionBarMinerCount(2); + await minersPage.clickMinerCheckbox(minerIp3); + await minersPage.validateActionBarMinerCount(3); + + await minersPage.clickActionsMenuButton(); + await minersPage.clickRebootButton(); + await minersPage.clickRebootConfirm(); + }); + + await test.step("Validate update process", async () => { + await minersPage.validateTextInToastGroup("Rebooting"); + await minersPage.validateTextInToastGroup("Rebooted"); + }); + + await test.step("Validate reboot API request", async () => { + const request = await requestPromise; + const response = await responsePromise; + test.expect(request.method()).toBe("POST"); + const requestBody = request.postDataJSON(); + test.expect(requestBody).toHaveProperty("deviceSelector"); + test.expect(requestBody.deviceSelector).toHaveProperty("includeDevices"); + test.expect(requestBody.deviceSelector.includeDevices).toHaveProperty("deviceIdentifiers"); + test.expect(requestBody.deviceSelector.includeDevices.deviceIdentifiers).toHaveLength(3); + test.expect(response.status()).toBe(200); + }); + }); + + test("MANAGE POWER for a single miner", async ({ minersPage, page, commonSteps }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + await test.step("Filter Proto miners as a workaround", async () => { + // Workaround: Antminer miners don't support MANAGE POWER action + await minersPage.filterRigMiners(); + }); + + const requestPromise1 = page.waitForRequest(/SetPowerTarget/); + const responsePromise1 = page.waitForResponse(/SetPowerTarget/); + + await test.step("Select first miner and set MAX power", async () => { + let minerIp = await minersPage.getMinerIpAddressByIndex(0); + await minersPage.clickMinerThreeDotsButton(minerIp); + await minersPage.clickManagePowerButton(); + await minersPage.clickMaxPowerOption(); + await minersPage.clickManagePowerConfirm(); + }); + + await test.step("Validate update process", async () => { + await minersPage.validateTextInToastGroup("Updating power settings"); + await minersPage.validateTextInToastGroup("Updated power settings"); + await minersPage.dismissToast(); + }); + + await test.step("Validate 'SetPowerTarget' API request", async () => { + const request = await requestPromise1; + const response = await responsePromise1; + const requestBody = request.postDataJSON(); + test.expect(request.method()).toBe("POST"); + test.expect(requestBody).toHaveProperty("performanceMode"); + test.expect(requestBody.performanceMode).toBe("PERFORMANCE_MODE_MAXIMUM_HASHRATE"); + test.expect(requestBody).toHaveProperty("deviceSelector"); + test.expect(requestBody.deviceSelector).toHaveProperty("includeDevices"); + test.expect(requestBody.deviceSelector.includeDevices).toHaveProperty("deviceIdentifiers"); + test.expect(requestBody.deviceSelector.includeDevices.deviceIdentifiers).toHaveLength(1); + test.expect(response.status()).toBe(200); + }); + + const requestPromise2 = page.waitForRequest(/SetPowerTarget/); + const responsePromise2 = page.waitForResponse(/SetPowerTarget/); + + await test.step("Select first miner and set REDUCE power", async () => { + let minerIp = await minersPage.getMinerIpAddressByIndex(0); + await minersPage.clickMinerThreeDotsButton(minerIp); + await minersPage.clickManagePowerButton(); + await minersPage.clickReducePowerOption(); + await minersPage.clickManagePowerConfirm(); + }); + + await test.step("Validate update process", async () => { + await minersPage.validateTextInToastGroup("Updating power settings"); + await minersPage.validateTextInToastGroup("Updated power settings"); + }); + + await test.step("Validate 'SetPowerTarget' API request", async () => { + const request = await requestPromise2; + const response = await responsePromise2; + const requestBody = request.postDataJSON(); + test.expect(request.method()).toBe("POST"); + test.expect(requestBody).toHaveProperty("performanceMode"); + test.expect(requestBody.performanceMode).toBe("PERFORMANCE_MODE_EFFICIENCY"); + test.expect(requestBody).toHaveProperty("deviceSelector"); + test.expect(requestBody.deviceSelector).toHaveProperty("includeDevices"); + test.expect(requestBody.deviceSelector.includeDevices).toHaveProperty("deviceIdentifiers"); + test.expect(requestBody.deviceSelector.includeDevices.deviceIdentifiers).toHaveLength(1); + test.expect(response.status()).toBe(200); + }); + }); + + test("MANAGE POWER for multiple miners", async ({ minersPage, page, commonSteps }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + await test.step("Filter Proto miners as a workaround", async () => { + // Workaround: Antminer miners don't support MANAGE POWER action + await minersPage.filterRigMiners(); + }); + + const requestPromise1 = page.waitForRequest(/SetPowerTarget/); + const responsePromise1 = page.waitForResponse(/SetPowerTarget/); + + await test.step("Select multiple miners and set MAX power", async () => { + let minerIp1 = await minersPage.getMinerIpAddressByIndex(0); + let minerIp2 = await minersPage.getMinerIpAddressByIndex(1); + let minerIp3 = await minersPage.getMinerIpAddressByIndex(2); + + await minersPage.clickMinerCheckbox(minerIp1); + await minersPage.validateActionBarMinerCount(1); + await minersPage.clickMinerCheckbox(minerIp2); + await minersPage.validateActionBarMinerCount(2); + await minersPage.clickMinerCheckbox(minerIp3); + await minersPage.validateActionBarMinerCount(3); + + await minersPage.clickActionsMenuButton(); + await minersPage.clickManagePowerButton(); + await minersPage.clickMaxPowerOption(); + await minersPage.clickManagePowerConfirm(); + }); + + await test.step("Validate update process", async () => { + await minersPage.validateTextInToastGroup("Updating power settings"); + await minersPage.validateTextInToastGroup("Updated power settings"); + await minersPage.dismissToast(); + }); + + await test.step("Validate 'SetPowerTarget' API request", async () => { + const request = await requestPromise1; + const response = await responsePromise1; + const requestBody = request.postDataJSON(); + test.expect(request.method()).toBe("POST"); + test.expect(requestBody).toHaveProperty("performanceMode"); + test.expect(requestBody.performanceMode).toBe("PERFORMANCE_MODE_MAXIMUM_HASHRATE"); + test.expect(requestBody).toHaveProperty("deviceSelector"); + test.expect(requestBody.deviceSelector).toHaveProperty("includeDevices"); + test.expect(requestBody.deviceSelector.includeDevices).toHaveProperty("deviceIdentifiers"); + test.expect(requestBody.deviceSelector.includeDevices.deviceIdentifiers).toHaveLength(3); + test.expect(response.status()).toBe(200); + }); + + const requestPromise2 = page.waitForRequest(/SetPowerTarget/); + const responsePromise2 = page.waitForResponse(/SetPowerTarget/); + + await test.step("Select multiple miners and set REDUCE power", async () => { + await minersPage.clickActionsMenuButton(); + await minersPage.clickManagePowerButton(); + await minersPage.clickReducePowerOption(); + await minersPage.clickManagePowerConfirm(); + }); + + await test.step("Validate update process", async () => { + await minersPage.validateTextInToastGroup("Updating power settings"); + await minersPage.validateTextInToastGroup("Updated power settings"); + }); + + await test.step("Validate 'SetPowerTarget' API request", async () => { + const request = await requestPromise2; + const response = await responsePromise2; + const requestBody = request.postDataJSON(); + test.expect(request.method()).toBe("POST"); + test.expect(requestBody).toHaveProperty("performanceMode"); + test.expect(requestBody.performanceMode).toBe("PERFORMANCE_MODE_EFFICIENCY"); + test.expect(requestBody).toHaveProperty("deviceSelector"); + test.expect(requestBody.deviceSelector).toHaveProperty("includeDevices"); + test.expect(requestBody.deviceSelector.includeDevices).toHaveProperty("deviceIdentifiers"); + test.expect(requestBody.deviceSelector.includeDevices.deviceIdentifiers).toHaveLength(3); + test.expect(response.status()).toBe(200); + }); + }); + + test("Set COOLING MODE to Air Cooled for a single miner", async ({ minersPage, page, commonSteps }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + await test.step("Filter Proto miners as a workaround", async () => { + // Workaround: Antminer miners don't support COOLING_MODE action + await minersPage.filterRigMiners(); + }); + + const requestPromise = page.waitForRequest(/SetCoolingMode/); + const responsePromise = page.waitForResponse(/SetCoolingMode/); + + await test.step("Select first miner and set Air Cooled mode", async () => { + const minerIp = await minersPage.getMinerIpAddressByIndex(0); + await minersPage.clickMinerThreeDotsButton(minerIp); + await minersPage.clickCoolingModeButton(); + await minersPage.validateAirCooledOptionSelected(); + await minersPage.clickUpdateCoolingModeConfirm(); + }); + + await test.step("Validate update process", async () => { + await minersPage.validateTextInToastGroup("Setting cooling mode"); + await minersPage.validateTextInToastGroup("Updated cooling mode"); + }); + + await test.step("Validate 'SetCoolingMode' API request", async () => { + const request = await requestPromise; + const response = await responsePromise; + const requestBody = request.postDataJSON(); + test.expect(request.method()).toBe("POST"); + test.expect(requestBody).toHaveProperty("mode"); + test.expect(requestBody.mode).toBe("COOLING_MODE_AIR_COOLED"); + test.expect(requestBody).toHaveProperty("deviceSelector"); + test.expect(requestBody.deviceSelector).toHaveProperty("includeDevices"); + test.expect(requestBody.deviceSelector.includeDevices).toHaveProperty("deviceIdentifiers"); + test.expect(requestBody.deviceSelector.includeDevices.deviceIdentifiers).toHaveLength(1); + test.expect(response.status()).toBe(200); + }); + }); + + test("Set COOLING MODE to Immersion Cooled for a single miner", async ({ minersPage, page, commonSteps }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + await test.step("Filter Proto miners as a workaround", async () => { + // Workaround: Antminer miners don't support COOLING_MODE action + await minersPage.filterRigMiners(); + }); + + const requestPromise = page.waitForRequest(/SetCoolingMode/); + const responsePromise = page.waitForResponse(/SetCoolingMode/); + + await test.step("Select first miner and set Immersion Cooled mode", async () => { + const minerIp = await minersPage.getMinerIpAddressByIndex(0); + await minersPage.clickMinerThreeDotsButton(minerIp); + await minersPage.clickCoolingModeButton(); + await minersPage.clickImmersionCooledOption(); + await minersPage.clickUpdateCoolingModeConfirm(); + }); + + await test.step("Validate update process", async () => { + await minersPage.validateTextInToastGroup("Setting cooling mode"); + await minersPage.validateTextInToastGroup("Updated cooling mode"); + }); + + await test.step("Validate 'SetCoolingMode' API request", async () => { + const request = await requestPromise; + const response = await responsePromise; + const requestBody = request.postDataJSON(); + test.expect(request.method()).toBe("POST"); + test.expect(requestBody).toHaveProperty("mode"); + test.expect(requestBody.mode).toBe("COOLING_MODE_IMMERSION_COOLED"); + test.expect(requestBody).toHaveProperty("deviceSelector"); + test.expect(requestBody.deviceSelector).toHaveProperty("includeDevices"); + test.expect(requestBody.deviceSelector.includeDevices).toHaveProperty("deviceIdentifiers"); + test.expect(requestBody.deviceSelector.includeDevices.deviceIdentifiers).toHaveLength(1); + test.expect(response.status()).toBe(200); + }); + }); + + test("Set COOLING MODE for multiple miners", async ({ minersPage, page, commonSteps }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + await test.step("Filter Proto miners as a workaround", async () => { + // Workaround: Antminer miners don't support COOLING_MODE action + await minersPage.filterRigMiners(); + }); + + const requestPromise = page.waitForRequest(/SetCoolingMode/); + const responsePromise = page.waitForResponse(/SetCoolingMode/); + + await test.step("Select multiple miners and set Air Cooled mode", async () => { + const minerIp1 = await minersPage.getMinerIpAddressByIndex(0); + const minerIp2 = await minersPage.getMinerIpAddressByIndex(1); + const minerIp3 = await minersPage.getMinerIpAddressByIndex(2); + + await minersPage.clickMinerCheckbox(minerIp1); + await minersPage.validateActionBarMinerCount(1); + await minersPage.clickMinerCheckbox(minerIp2); + await minersPage.validateActionBarMinerCount(2); + await minersPage.clickMinerCheckbox(minerIp3); + await minersPage.validateActionBarMinerCount(3); + + await minersPage.clickActionsMenuButton(); + await minersPage.clickCoolingModeButton(); + await minersPage.clickAirCooledOption(); + await minersPage.clickUpdateCoolingModeConfirm(); + }); + + await test.step("Validate update process", async () => { + await minersPage.validateTextInToastGroup("Setting cooling mode"); + await minersPage.validateTextInToastGroup("Updated cooling mode"); + }); + + await test.step("Validate 'SetCoolingMode' API request", async () => { + const request = await requestPromise; + const response = await responsePromise; + const requestBody = request.postDataJSON(); + test.expect(request.method()).toBe("POST"); + test.expect(requestBody).toHaveProperty("mode"); + test.expect(requestBody.mode).toBe("COOLING_MODE_AIR_COOLED"); + test.expect(requestBody).toHaveProperty("deviceSelector"); + test.expect(requestBody.deviceSelector).toHaveProperty("includeDevices"); + test.expect(requestBody.deviceSelector.includeDevices).toHaveProperty("deviceIdentifiers"); + test.expect(requestBody.deviceSelector.includeDevices.deviceIdentifiers).toHaveLength(3); + test.expect(response.status()).toBe(200); + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/minersAddRemove.spec.ts b/client/e2eTests/protoFleet/spec/minersAddRemove.spec.ts new file mode 100644 index 000000000..bdc1353a9 --- /dev/null +++ b/client/e2eTests/protoFleet/spec/minersAddRemove.spec.ts @@ -0,0 +1,222 @@ +/* eslint-disable playwright/expect-expect */ +import { testConfig } from "../config/test.config"; +import { test } from "../fixtures/pageFixtures"; +import { CommonSteps } from "../helpers/commonSteps"; +import { AddMinersPage } from "../pages/addMiners"; +import { AuthPage } from "../pages/auth"; +import { HomePage } from "../pages/home"; +import { MinersPage } from "../pages/miners"; + +test.describe("Miners UNPAIR - ADD actions", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test.afterEach("CLEANUP: Add miners, re-authenticate", async ({ browser }, testInfo) => { + if (testConfig.target === "real") { + return; + } + + const isMobile = testInfo.project.use?.isMobile ?? false; + const context = await browser.newContext({ baseURL: testConfig.baseUrl }); + try { + const page = await context.newPage(); + await page.goto("/"); + + const homePage = new HomePage(page, isMobile); + const authPage = new AuthPage(page, isMobile); + const minersPage = new MinersPage(page, isMobile); + const addMinersPage = new AddMinersPage(page, isMobile); + const commonSteps = new CommonSteps(authPage, minersPage); + + await commonSteps.loginAsAdmin(); + + // Step 1: Add miners from network if any are available + await minersPage.navigateToMinersPage(); + + const addMinersButtonClicked = await minersPage.tryAction(() => minersPage.clickAddMinersButton()); + if (!addMinersButtonClicked) { + await authPage.clickGetStarted(); + } + await addMinersPage.clickFindMinersInNetwork(); + await addMinersPage.waitForFoundMinersList(); + const foundMinerCount = await addMinersPage.getFoundMinersCount(); + + if (foundMinerCount === 0) { + await addMinersPage.clickHeaderIconButton(); + } else { + await addMinersPage.clickContinueWithSelectedMiners(); + } + await minersPage.waitForMinersListToLoad(); + + // Step 2: Re-authenticate miners if needed (existing logic) + const authenticateMinersButtonClicked = await homePage.tryAction(() => homePage.clickAuthenticateMinersButton()); + if (authenticateMinersButtonClicked) { + await homePage.validateAuthenticateMinersModalTitle(); + await homePage.clickShowMinersButton(); + const miners = await homePage.getListOfMinersToAuthenticate(); + + if (miners.some((miner) => miner.includes("S17 XP"))) { + await homePage.inputMinerAuthUsername("root17"); + await homePage.inputMinerAuthPassword("root17"); + await homePage.clickAuthenticateMinersConfirmButton(); + } + if (miners.some((miner) => miner.includes("S19 XP"))) { + await homePage.inputMinerAuthUsername("root19"); + await homePage.inputMinerAuthPassword("root19"); + await homePage.clickAuthenticateMinersConfirmButton(); + } + if (miners.some((miner) => miner.includes("S21 XP"))) { + await homePage.inputMinerAuthUsername("root21"); + await homePage.inputMinerAuthPassword("root21"); + await homePage.clickAuthenticateMinersConfirmButton(); + } + await homePage.validateModalClosed(); + } + } finally { + await context.close(); + } + }); + + test("UNPAIR - ADD a single miner", async ({ minersPage, commonSteps, addMinersPage }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + let minerCount: number; + let minerIp: string; + + await test.step("Select a miner and unpair it", async () => { + minerCount = await minersPage.getMinersCount(); + minerIp = await minersPage.getAuthenticatedMinerIpAddressByIndex(0); + await minersPage.clickMinerThreeDotsButton(minerIp); + await minersPage.clickUnpairButton(); + await minersPage.clickUnpairConfirm(); + }); + + await test.step("Validate miner was unpaired", async () => { + await minersPage.validateMinerNotPresent(minerIp); + await minersPage.validateAmountOfMiners(minerCount - 1); + }); + + await test.step("Add a single miner", async () => { + await minersPage.clickAddMinersButton(); + await addMinersPage.clickFindMinersInNetwork(); + await addMinersPage.clickContinueWithXMiners(1); + }); + + await test.step("Validate miner was added", async () => { + await minersPage.waitForMinersTitle(); + await minersPage.waitForMinersListToLoad(); + await minersPage.validateMinerInList(minerIp); + await minersPage.validateAmountOfMiners(minerCount); + }); + }); + + test("UNPAIR - ADD multiple miners", async ({ minersPage, commonSteps, addMinersPage }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + let minerCount: number; + let minerIp1: string; + let minerIp2: string; + let minerIp3: string; + + await test.step("Select multiple miners and unpair them", async () => { + minerCount = await minersPage.getMinersCount(); + minerIp1 = await minersPage.getAuthenticatedMinerIpAddressByIndex(0); + minerIp2 = await minersPage.getAuthenticatedMinerIpAddressByIndex(1); + minerIp3 = await minersPage.getAuthenticatedMinerIpAddressByIndex(2); + await minersPage.clickMinerCheckbox(minerIp1); + await minersPage.validateActionBarMinerCount(1); + await minersPage.clickMinerCheckbox(minerIp2); + await minersPage.validateActionBarMinerCount(2); + await minersPage.clickMinerCheckbox(minerIp3); + await minersPage.validateActionBarMinerCount(3); + await minersPage.clickActionsMenuButton(); + await minersPage.clickUnpairButton(); + await minersPage.clickUnpairConfirm(); + }); + + await test.step("Validate miners were unpaired", async () => { + await minersPage.validateMinerNotPresent(minerIp1); + await minersPage.validateMinerNotPresent(minerIp2); + await minersPage.validateMinerNotPresent(minerIp3); + await minersPage.validateAmountOfMiners(minerCount - 3); + }); + + await test.step("Add multiple miners", async () => { + await minersPage.clickAddMinersButton(); + await addMinersPage.clickFindMinersInNetwork(); + await addMinersPage.clickChooseMiners(); + await addMinersPage.clickSelectNone(); + await addMinersPage.clickMinerCheckbox(minerIp1); + await addMinersPage.clickMinerCheckbox(minerIp2); + await addMinersPage.clickMinerCheckbox(minerIp3); + await addMinersPage.clickDone(); + await addMinersPage.clickContinueWithXMiners(3); + }); + + await test.step("Validate miners were added", async () => { + await minersPage.waitForMinersTitle(); + await minersPage.waitForMinersListToLoad(); + await minersPage.validateMinerInList(minerIp1); + await minersPage.validateMinerInList(minerIp2); + await minersPage.validateMinerInList(minerIp3); + await minersPage.validateAmountOfMiners(minerCount); + }); + }); + + test("UNPAIR - ADD all miners", async ({ minersPage, commonSteps, addMinersPage }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + let originalMinerCount: number; + const allMinerIps: string[] = []; + + await test.step("Capture all miner IPs and select all miners", async () => { + originalMinerCount = await minersPage.getMinersCount(); + + for (let i = 0; i < originalMinerCount; i++) { + const minerIp = await minersPage.getMinerIpAddressByIndex(i); + allMinerIps.push(minerIp); + } + + await minersPage.clickSelectAllCheckbox(); + await minersPage.validateActionBarMinerCount(originalMinerCount); + }); + + await test.step("Unpair all miners", async () => { + await minersPage.clickActionsMenuButton(); + await minersPage.clickUnpairButton(); + await minersPage.clickUnpairConfirm(); + }); + + await test.step("Validate all miners were unpaired", async () => { + for (const minerIp of allMinerIps) { + await minersPage.validateMinerNotPresent(minerIp); + } + await minersPage.validateAmountOfMiners(0); + }); + + await test.step("Validate null state - no miners added", async () => { + await minersPage.validateTextIsVisible("You haven't paired any miners"); + await minersPage.validateTextIsVisible("Add miners to your fleet to get started."); + }); + + await test.step("Add all miners back using onboarding flow", async () => { + await minersPage.clickGetStarted(); + await addMinersPage.clickFindMinersInNetwork(); + await addMinersPage.clickContinueWithSelectedMiners(); + }); + + await test.step("Validate all miners were added back", async () => { + await minersPage.waitForMinersTitle(); + await minersPage.waitForMinersListToLoad(); + await minersPage.validateMinersAdded(originalMinerCount); + + for (const minerIp of allMinerIps) { + await minersPage.validateMinerInList(minerIp); + } + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/minersRename.spec.ts b/client/e2eTests/protoFleet/spec/minersRename.spec.ts new file mode 100644 index 000000000..112a5cf10 --- /dev/null +++ b/client/e2eTests/protoFleet/spec/minersRename.spec.ts @@ -0,0 +1,434 @@ +import { testConfig } from "../config/test.config"; +import { expect, test } from "../fixtures/pageFixtures"; +import { CommonSteps } from "../helpers/commonSteps"; +import { generateRandomText } from "../helpers/testDataHelper"; +import { AuthPage } from "../pages/auth"; +import { MinersPage } from "../pages/miners"; + +const BULK_RENAME_PROPERTIES = [ + "custom", + "fixed-mac-address", + "fixed-serial-number", + "fixed-model", + "fixed-manufacturer", +] as const; + +const COUNTER_SCALE = { + MIN: 1, + MAX: 6, + DEFAULT: 2, +} as const; + +const COUNTER_START = { + DEFAULT: 1, + SINGLE_DIGIT: 5, + DOUBLE_DIGIT: 56, + TRIPLE_DIGIT: 567, +} as const; + +const CHARACTER_COUNT = { + MIN: 1, + MAX: 6, +} as const; + +const SEPARATORS_THAT_CHANGE_NAME = [ + { id: "dash", value: "-" }, + { id: "underscore", value: "_" }, + { id: "none", value: "" }, +] as const; + +const BULK_RENAME_COUNTER_PREVIEW = String(COUNTER_START.DEFAULT).padStart(COUNTER_SCALE.DEFAULT, "0"); + +test.describe("Miners Rename", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test.afterEach("CLEANUP: Rename back to just model names", async ({ browser }, testInfo) => { + // CLEANUP: Rename back to just model names + const isMobile = testInfo.project.use?.isMobile ?? false; + const context = await browser.newContext({ baseURL: testConfig.baseUrl }); + const page = await context.newPage(); + await page.goto("/"); + + try { + const authPage = new AuthPage(page, isMobile); + const minersPage = new MinersPage(page, isMobile); + const commonSteps = new CommonSteps(authPage, minersPage); + + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + await minersPage.clickSelectAllCheckbox(); + await minersPage.clickActionsMenuButton(); + await minersPage.clickRenameButton(); + await minersPage.validateBulkRenamePageOpened(); + + for (const propertyId of BULK_RENAME_PROPERTIES) { + await minersPage.toggleBulkRenameProperty(propertyId, propertyId === "fixed-model"); + } + + await minersPage.clickBulkRenameSave(); + await minersPage.confirmBulkRenameWarningsIfPresent(); + await minersPage.waitForMinersTitle(); + await minersPage.waitForMinersListToLoad(); + } finally { + await context.close(); + } + }); + + test("Validate bulk rename functionality", async ({ minersPage, commonSteps }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + await minersPage.setBulkRenamePropertyOrder(BULK_RENAME_PROPERTIES); + + const minerCount = await minersPage.getMinersCount(); + + await test.step("Select all miners and open bulk rename", async () => { + await minersPage.clickSelectAllCheckbox(); + await minersPage.clickActionsMenuButton(); + await minersPage.clickRenameButton(); + await minersPage.validateBulkRenamePageOpened(); + expect((await minersPage.getBulkRenamePropertyOrder())[0]).toBe("custom"); + }); + + await test.step("Enable all rename properties", async () => { + for (const propertyId of BULK_RENAME_PROPERTIES) { + await minersPage.toggleBulkRenameProperty(propertyId, true); + } + + await minersPage.setCustomBulkRenameCounterScale(COUNTER_SCALE.DEFAULT); + }); + + await test.step("Select period separator", async () => { + await minersPage.selectBulkRenameSeparator("period"); + }); + + await test.step("Apply rename and wait for names update", async () => { + await minersPage.clickBulkRenameSave(); + await minersPage.waitForMinersTitle(); + await minersPage.waitForMinersListToLoad(); + + const expectedMinSegmentCount = BULK_RENAME_PROPERTIES.length - 1; + const expectedMaxSegmentCount = BULK_RENAME_PROPERTIES.length; + + await expect + .poll( + async () => { + const names = await minersPage.getMinerNames(); + return names.every((name) => { + const segments = name.split("."); + return ( + /^\d+$/.test(segments[0] ?? "") && + segments.length >= expectedMinSegmentCount && + segments.length <= expectedMaxSegmentCount + ); + }); + }, + { message: "Waiting for miner names to update with new format" }, + ) + .toBe(true); + }); + + await test.step("Validate renamed miner names", async () => { + const names = await minersPage.getMinerNames(); + expect(names).toHaveLength(minerCount); + + const expectedMinSegmentCount = BULK_RENAME_PROPERTIES.length - 1; + const expectedMaxSegmentCount = BULK_RENAME_PROPERTIES.length; + const counters: number[] = []; + + for (const name of names) { + const segments = name.split("."); + expect( + segments.length, + `Name should have between ${expectedMinSegmentCount} and ${expectedMaxSegmentCount} segments`, + ).toBeGreaterThanOrEqual(expectedMinSegmentCount); + expect( + segments.length, + `Name should have between ${expectedMinSegmentCount} and ${expectedMaxSegmentCount} segments`, + ).toBeLessThanOrEqual(expectedMaxSegmentCount); + + // Validate no empty segments + const emptySegmentIndices = segments.map((s, i) => (s.trim() === "" ? i : -1)).filter((i) => i >= 0); + expect( + emptySegmentIndices, + `Name "${name}" contains empty segments at positions: ${emptySegmentIndices.join(", ")}`, + ).toHaveLength(0); + + const counterSegment = segments[0]; + expect( + /^\d+$/.test(counterSegment), + `First segment should be numeric counter (validates 'custom' is first), got: "${counterSegment}" in "${name}"`, + ).toBe(true); + + const counter = parseInt(counterSegment, 10); + expect(counter, `Counter should be positive, got: ${counter}`).toBeGreaterThan(0); + counters.push(counter); + } + + const sortedCounters = [...counters].sort((a, b) => a - b); + const expectedSequence = Array.from({ length: minerCount }, (_, i) => i + 1); + expect(sortedCounters, "Counters should be sequential from 1 to N").toEqual(expectedSequence); + }); + }); + + test("Configure each miner rename property", async ({ minersPage, commonSteps }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + const minerCount = await minersPage.getMinersCount(); + expect(minerCount, "At least one miner must be available").toBeGreaterThan(0); + const minerName = await minersPage.getMinerNameByIndex(0); + const fixedProperties = BULK_RENAME_PROPERTIES.filter((p) => p !== "custom"); + const fixedPropertyValues = new Map<(typeof fixedProperties)[number], string>(); + let propertyOrder: string[] = []; + + await test.step("Open bulk rename for a single miner", async () => { + await minersPage.clickMinerCheckboxByIndex(0); + await minersPage.clickActionsMenuButton(); + await minersPage.clickRenameButton(); + await minersPage.validateBulkRenamePageOpened(); + + for (const propertyId of BULK_RENAME_PROPERTIES) { + await minersPage.toggleBulkRenameProperty(propertyId, true); + } + await minersPage.setCustomBulkRenameCounterScale(COUNTER_SCALE.DEFAULT); + propertyOrder = await minersPage.getBulkRenamePropertyOrder(); + }); + + await test.step("Capture fixed property preview values", async () => { + for (const propertyId of fixedProperties) { + await minersPage.clickBulkRenamePropertyOptions(propertyId); + fixedPropertyValues.set(propertyId, await minersPage.getFixedValuePreviewText()); + await minersPage.dismissRenameOptionsModal(); + } + }); + + const previewSegments = propertyOrder + .map((propertyId) => { + if (propertyId === "custom") { + return BULK_RENAME_COUNTER_PREVIEW; + } + + return fixedPropertyValues.get(propertyId as (typeof fixedProperties)[number]) ?? ""; + }) + .filter((segment) => segment.trim() !== ""); + + await test.step("Validate period separator preview behavior", async () => { + await minersPage.selectBulkRenameSeparator("period"); + const expectedPeriodPreviewName = previewSegments.join("."); + await minersPage.validateBulkRenamePreviewState(expectedPeriodPreviewName, minerName); + }); + + await test.step("Validate other separators update the new name", async () => { + for (const separator of SEPARATORS_THAT_CHANGE_NAME) { + await minersPage.selectBulkRenameSeparator(separator.id); + const expectedPreviewName = previewSegments.join(separator.value); + await minersPage.waitForBulkRenamePreviewName(expectedPreviewName); + } + }); + + await test.step("Toggle all properties off except custom", async () => { + for (const propertyId of BULK_RENAME_PROPERTIES) { + await minersPage.toggleBulkRenameProperty(propertyId, propertyId === "custom"); + } + }); + + await test.step("Validate custom property options preview behavior", async () => { + await minersPage.clickBulkRenamePropertyOptions("custom"); + + // Make the initial expectations deterministic. + await minersPage.selectCustomPropertyType("string-and-counter"); + await minersPage.fillCustomPropertyCounterStart(COUNTER_START.DEFAULT); + await minersPage.clickCustomPropertyCounterScale(COUNTER_SCALE.DEFAULT); + + await minersPage.fillCustomPropertyPrefix("pre"); + await minersPage.validateCustomPropertyPreviewText("pre01"); + + await minersPage.fillCustomPropertyPrefix(""); + await minersPage.fillCustomPropertySuffix("suf"); + await minersPage.validateCustomPropertyPreviewText("01suf"); + + await minersPage.fillCustomPropertyPrefix("pre"); + await minersPage.validateCustomPropertyPreviewText("pre01suf"); + + await minersPage.fillCustomPropertyCounterStart(""); + await minersPage.validateCustomPropertySaveDisabled(); + + await minersPage.fillCustomPropertyCounterStart(COUNTER_START.SINGLE_DIGIT); + await minersPage.validateCustomPropertyPreviewText("pre05suf"); + + await minersPage.fillCustomPropertyCounterStart(COUNTER_START.DOUBLE_DIGIT); + await minersPage.validateCustomPropertyPreviewText("pre56suf"); + + await minersPage.fillCustomPropertyCounterStart(COUNTER_START.TRIPLE_DIGIT); + await minersPage.validateCustomPropertyPreviewText("pre567suf"); + + await minersPage.fillCustomPropertyCounterStart(COUNTER_START.SINGLE_DIGIT); + + for (let scale = COUNTER_SCALE.MIN; scale <= COUNTER_SCALE.MAX; scale++) { + await minersPage.clickCustomPropertyCounterScale(scale); + const paddedCounterValue = String(COUNTER_START.SINGLE_DIGIT).padStart(scale, "0"); + await minersPage.validateCustomPropertyPreviewText(`pre${paddedCounterValue}suf`); + } + + await minersPage.clickCustomPropertyCounterScale(COUNTER_SCALE.MIN); + await minersPage.selectCustomPropertyType("counter-only"); + await minersPage.validateCustomPropertyPreviewText(String(COUNTER_START.SINGLE_DIGIT)); + + await minersPage.selectCustomPropertyType("string-only"); + await minersPage.fillCustomPropertyStringValue("sometext"); + await minersPage.validateCustomPropertyPreviewText("sometext"); + + await minersPage.dismissRenameOptionsModal(); + await minersPage.toggleBulkRenameProperty("custom", false); + }); + + await test.step("Validate fixed property options preview behavior", async () => { + for (const propertyId of fixedProperties) { + const fullValue = fixedPropertyValues.get(propertyId) ?? ""; + + await minersPage.toggleBulkRenameProperty(propertyId, true); + await minersPage.clickBulkRenamePropertyOptions(propertyId); + + await minersPage.validateFixedValuePreviewText(fullValue); + + // String section options only render when character count is not "All". + await minersPage.clickFixedValueCharacterCountOption(CHARACTER_COUNT.MIN); + + await minersPage.clickFixedValueStringSectionOption("first"); + for (let count = CHARACTER_COUNT.MIN; count <= CHARACTER_COUNT.MAX; count++) { + await minersPage.clickFixedValueCharacterCountOption(count); + await minersPage.validateFixedValuePreviewText(fullValue.slice(0, count)); + } + + await minersPage.clickFixedValueStringSectionOption("last"); + for (let count = CHARACTER_COUNT.MIN; count <= CHARACTER_COUNT.MAX; count++) { + await minersPage.clickFixedValueCharacterCountOption(count); + await minersPage.validateFixedValuePreviewText(fullValue.slice(-count)); + } + + await minersPage.dismissRenameOptionsModal(); + await minersPage.toggleBulkRenameProperty(propertyId, false); + } + }); + }); + + test("RENAME a single miner", async ({ minersPage, page, commonSteps }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + const requestPromise = page.waitForRequest(/RenameMiners/); + const responsePromise = page.waitForResponse(/RenameMiners/); + + const newName = generateRandomText("Renamed Miner E2E"); + let minerIp: string; + + await test.step("Select first miner and rename it", async () => { + minerIp = await minersPage.getMinerIpAddressByIndex(0); + await minersPage.clickMinerThreeDotsButton(minerIp); + await minersPage.clickRenameButton(); + await minersPage.fillRenameInput(newName); + await minersPage.clickRenameSave(); + }); + + await test.step("Validate update process", async () => { + await minersPage.validateTextInToastGroup("Miner renamed"); + }); + + await test.step("Validate 'RenameMiners' API request", async () => { + const request = await requestPromise; + const response = await responsePromise; + const requestBody = request.postDataJSON(); + test.expect(request.method()).toBe("POST"); + test.expect(requestBody).toHaveProperty("deviceSelector"); + test.expect(requestBody.deviceSelector).toHaveProperty("includeDevices"); + test.expect(requestBody.deviceSelector.includeDevices).toHaveProperty("deviceIdentifiers"); + test.expect(requestBody.deviceSelector.includeDevices.deviceIdentifiers).toHaveLength(1); + test.expect(response.status()).toBe(200); + }); + + await test.step("Validate name updated in miner list", async () => { + await minersPage.validateMinerName(minerIp, newName); + }); + }); + + test("BULK RENAME multiple miners", async ({ minersPage, page, commonSteps }, testInfo) => { + // eslint-disable-next-line playwright/no-skipped-test + test.skip(testInfo.project.use?.isMobile === true, "Desktop-only bulk rename flow"); + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + const requestPromise = page.waitForRequest(/RenameMiners/); + const responsePromise = page.waitForResponse(/RenameMiners/); + + let minerIp1: string; + let minerIp2: string; + + await test.step("Select two rig miners and open bulk rename", async () => { + await minersPage.filterRigMiners(); + minerIp1 = await minersPage.getMinerIpAddressByIndex(0); + minerIp2 = await minersPage.getMinerIpAddressByIndex(1); + await minersPage.clickMinerCheckbox(minerIp1); + await minersPage.validateActionBarMinerCount(1); + await minersPage.clickMinerCheckbox(minerIp2); + await minersPage.validateActionBarMinerCount(2); + await minersPage.clickActionsMenuButton(); + await minersPage.clickRenameButton(); + await minersPage.validateBulkRenamePageOpened(); + }); + + await test.step("Enable MAC address and validate preview updates", async () => { + await minersPage.clickBulkRenamePropertyToggle("fixed-mac-address"); + await test.expect(page.getByTestId("bulk-rename-desktop-preview")).toContainText(/([0-9a-f]{2}:){2}/i); + }); + + await test.step("Save the bulk rename", async () => { + await minersPage.clickBulkRenameSave(); + }); + + await test.step("Validate update process", async () => { + await minersPage.validateTextInToastGroup("Renamed 2 miners"); + }); + + await test.step("Validate 'RenameMiners' API request", async () => { + const request = await requestPromise; + const response = await responsePromise; + const requestBody = request.postDataJSON(); + test.expect(request.method()).toBe("POST"); + test.expect(requestBody.deviceSelector.includeDevices.deviceIdentifiers).toHaveLength(2); + test.expect(requestBody.nameConfig.properties).toHaveLength(1); + test.expect(requestBody.nameConfig.separator).toBe("-"); + test.expect(response.status()).toBe(200); + }); + }); + + test("BULK RENAME mobile layout", async ({ minersPage, page, commonSteps }, testInfo) => { + // eslint-disable-next-line playwright/no-skipped-test + test.skip(testInfo.project.use?.isMobile !== true, "Mobile-only bulk rename layout"); + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + await test.step("Open bulk rename from selected miners", async () => { + await minersPage.filterRigMiners(); + const minerIp1 = await minersPage.getMinerIpAddressByIndex(0); + const minerIp2 = await minersPage.getMinerIpAddressByIndex(1); + await minersPage.clickMinerCheckbox(minerIp1); + await minersPage.clickMinerCheckbox(minerIp2); + await minersPage.clickActionsMenuButton(); + await minersPage.clickRenameButton(); + await minersPage.validateBulkRenamePageOpened(); + }); + + await test.step("Validate mobile preview and fixed-value options sheet", async () => { + await test.expect(page.getByTestId("bulk-rename-mobile-preview")).toBeVisible(); + await minersPage.clickBulkRenamePropertyToggle("fixed-mac-address"); + await minersPage.clickBulkRenamePropertyOptions("fixed-mac-address"); + await minersPage.validateTextIsVisible("Number of characters"); + await test.expect(page.getByTestId("fixed-value-options-save-button-mobile")).toBeVisible(); + await page.getByTestId("fixed-value-options-save-button-mobile").click(); + await minersPage.validateBulkRenamePageOpened(); + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/minersSleepWake.spec.ts b/client/e2eTests/protoFleet/spec/minersSleepWake.spec.ts new file mode 100644 index 000000000..fae959705 --- /dev/null +++ b/client/e2eTests/protoFleet/spec/minersSleepWake.spec.ts @@ -0,0 +1,194 @@ +/* eslint-disable playwright/expect-expect */ +import { expect } from "@playwright/test"; +import { testConfig } from "../config/test.config"; +import { test } from "../fixtures/pageFixtures"; +import { CommonSteps } from "../helpers/commonSteps"; +import { AuthPage } from "../pages/auth"; +import { MinersPage } from "../pages/miners"; + +async function ensureVisibleMinersAwake(minersPage: MinersPage) { + const hasSleepingMiners = await minersPage.hasAnyMinerWithStatus("Sleeping"); + const hasWakingMiners = await minersPage.hasAnyMinerWithStatus("Waking"); + + if (!hasSleepingMiners && !hasWakingMiners) { + return; + } + + await minersPage.clickSelectAllCheckbox(); + await minersPage.clickActionsMenuButton(); + await minersPage.clickWakeUpButton(); + await minersPage.clickWakeUpConfirm(); + await minersPage.validateNoMinerWithStatus("Sleeping"); + await minersPage.validateNoMinerWithStatus("Waking"); +} + +test.describe("Miners SLEEP - WAKE actions", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test.afterEach("CLEANUP: wake up miners", async ({ browser }, testInfo) => { + if (testConfig.target === "real") { + return; + } + + const isMobile = testInfo.project.use?.isMobile ?? false; + const context = await browser.newContext({ baseURL: testConfig.baseUrl }); + try { + const page = await context.newPage(); + await page.goto("/"); + + const authPage = new AuthPage(page, isMobile); + const minersPage = new MinersPage(page, isMobile); + const commonSteps = new CommonSteps(authPage, minersPage); + + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + await ensureVisibleMinersAwake(minersPage); + } finally { + await context.close(); + } + }); + + test("SLEEP - WAKE a miner", async ({ minersPage, commonSteps }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + await test.step("Filter Proto rig miners", async () => { + await minersPage.filterRigMiners(); + await ensureVisibleMinersAwake(minersPage); + }); + + let minerIp: string; + + await test.step("Select first miner and shut it down", async () => { + minerIp = await minersPage.getMinerIpAddressByStatus("Hashing"); + await minersPage.clickMinerThreeDotsButton(minerIp); + await minersPage.clickShutdownButton(); + await minersPage.clickShutdownConfirm(); + }); + + await test.step("Validate update process", async () => { + await minersPage.validateTextInToastGroup("Putting miners to sleep"); + await minersPage.validateTextInToastGroup("Put 1 out of 1 miners to sleep"); + }); + + await test.step("Validate miner is sleeping", async () => { + await minersPage.validateMinerStatusSettled(minerIp, "Sleeping"); + }); + + await test.step("Select all miners and wake them up", async () => { + await minersPage.clickMinerThreeDotsButton(minerIp); + await minersPage.clickWakeUpButton(); + await minersPage.clickWakeUpConfirm(); + }); + + await test.step("Validate update process", async () => { + await minersPage.validateTextInToastGroup("Waking up miners"); + await minersPage.validateTextInToastGroup(`Woke up 1 out of 1 miners`); + }); + + await test.step("Validate none of the miners are sleeping", async () => { + await minersPage.validateMinerStatusSettled(minerIp, "Hashing"); + await minersPage.validateNoMinerWithStatus("Sleeping"); + await minersPage.validateNoMinerWithStatus("Waking"); + }); + }); + + test("SLEEP - WAKE all rig miners, without page refresh", async ({ minersPage, commonSteps }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + await test.step("Filter Proto rig miners", async () => { + await minersPage.filterRigMiners(); + await ensureVisibleMinersAwake(minersPage); + }); + + let minerCount: number; + + await test.step("Select all miners and put them to sleep", async () => { + await minersPage.clickSelectAllCheckbox(); + minerCount = await minersPage.getMinersCount(); + await minersPage.clickActionsMenuButton(); + await minersPage.clickShutdownButton(); + await minersPage.clickShutdownConfirm(); + }); + + await test.step("Validate sleep process", async () => { + await minersPage.validateTextInToastGroup("Putting miners to sleep"); + await minersPage.validateTextInToastGroup(`Put ${minerCount} out of ${minerCount} miners to sleep`); + }); + + await test.step("Validate all miners are sleeping", async () => { + await minersPage.validateAllMinersStatusSettled("Sleeping"); + }); + + await test.step("Select all miners and wake them up", async () => { + await minersPage.clickSelectAllCheckbox(); + await minersPage.clickActionsMenuButton(); + await minersPage.clickWakeUpButton(); + await minersPage.clickWakeUpConfirm(); + }); + + await test.step("Validate wake up process", async () => { + await minersPage.validateTextInToastGroup("Waking up miners"); + await minersPage.validateTextInToastGroup(`Woke up ${minerCount} out of ${minerCount} miners`); + }); + + await test.step("Validate all miners are awake", async () => { + await minersPage.validateAllMinersStatusSettled("Hashing"); + }); + }); + + test("SLEEP - WAKE all non-rig miners, without page refresh", async ({ minersPage, commonSteps }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + let initialMinerStatuses: Array<{ ipAddress: string; status: string }>; + + await test.step("Filter all miners except Proto Rig", async () => { + await minersPage.filterAllMinersExceptRig(); + await ensureVisibleMinersAwake(minersPage); + initialMinerStatuses = await minersPage.getVisibleMinerStatuses(); + }); + + let minerCount: number; + + await test.step("Select all non-rig miners and put them to sleep", async () => { + minerCount = initialMinerStatuses.length; + expect(minerCount).toBeGreaterThan(0); + + await minersPage.clickSelectAllCheckbox(); + await minersPage.clickActionsMenuButton(); + await minersPage.clickShutdownButton(); + await minersPage.clickShutdownConfirm(); + }); + + await test.step("Validate sleep process", async () => { + await minersPage.validateTextInToastGroup("Putting miners to sleep"); + await minersPage.validateTextInToastGroup(`Put ${minerCount} out of ${minerCount} miners to sleep`); + }); + + await test.step("Validate all non-rig miners are sleeping", async () => { + await minersPage.validateAllMinersStatusSettled("Sleeping"); + }); + + await test.step("Select all non-rig miners and wake them up", async () => { + await minersPage.clickSelectAllCheckbox(); + await minersPage.clickActionsMenuButton(); + await minersPage.clickWakeUpButton(); + await minersPage.clickWakeUpConfirm(); + }); + + await test.step("Validate wake up process", async () => { + await minersPage.validateTextInToastGroup("Waking up miners"); + await minersPage.validateTextInToastGroup(`Woke up ${minerCount} out of ${minerCount} miners`); + }); + + await test.step("Validate all non-rig miners returned to their initial statuses", async () => { + for (const miner of initialMinerStatuses) { + await minersPage.validateMinerStatusSettled(miner.ipAddress, miner.status); + } + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/navigation.spec.ts b/client/e2eTests/protoFleet/spec/navigation.spec.ts new file mode 100644 index 000000000..68e1854aa --- /dev/null +++ b/client/e2eTests/protoFleet/spec/navigation.spec.ts @@ -0,0 +1,110 @@ +/* eslint-disable playwright/expect-expect */ +import { testConfig } from "../config/test.config"; +import { test } from "../fixtures/pageFixtures"; + +test.describe("Navigation", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test("Overview navigation", async ({ homePage, minersPage, commonSteps }) => { + await commonSteps.loginAsAdmin(); + + await test.step("Navigate to control board issues", async () => { + await homePage.clickControlBoardsLink(); + await minersPage.validateMinersPageOpened(); + await minersPage.validateActiveFilter("Control board issue"); + }); + + await test.step("Navigate back to overview", async () => { + await homePage.navigateToHomePage(); + await homePage.validateHomePageOpened(); + }); + + await test.step("Navigate to fan issues", async () => { + await homePage.clickFansLink(); + await minersPage.validateMinersPageOpened(); + await minersPage.validateActiveFilter("Fan issue"); + }); + + await test.step("Navigate back to overview", async () => { + await homePage.navigateToHomePage(); + await homePage.validateHomePageOpened(); + }); + + await test.step("Navigate to hashboard issues", async () => { + await homePage.clickHashboardsLink(); + await minersPage.validateMinersPageOpened(); + await minersPage.validateActiveFilter("Hash board issue"); + }); + + await test.step("Navigate back to overview", async () => { + await homePage.navigateToHomePage(); + await homePage.validateHomePageOpened(); + }); + + await test.step("Navigate to power supply issues", async () => { + await homePage.clickPowerSuppliesLink(); + await minersPage.validateMinersPageOpened(); + await minersPage.validateActiveFilter("PSU issue"); + }); + + await test.step("Navigate back to overview", async () => { + await homePage.navigateToHomePage(); + await homePage.validateHomePageOpened(); + }); + }); + + test("Navigate between main pages and settings sub-pages", async ({ authPage, settingsPage }) => { + await test.step("Log in as admin user", async () => { + await authPage.inputUsername(testConfig.users.admin.username); + await authPage.inputPassword(testConfig.users.admin.password); + await authPage.clickLogin(); + await authPage.validateLoggedIn(); + }); + + await test.step("Navigate from Home to Settings page", async () => { + await authPage.navigateToSettingsPage(); + }); + + await test.step("Navigate from Settings to Team Settings", async () => { + await settingsPage.navigateToTeamSettings(); + }); + + await test.step("Navigate from Team Settings back to Settings page", async () => { + await settingsPage.navigateToSettingsPage(); + }); + + await test.step("Navigate from Settings to Home page", async () => { + await settingsPage.navigateToHomePage(); + }); + + await test.step("Navigate from Home to Team Settings", async () => { + await settingsPage.navigateToTeamSettings(); + }); + + await test.step("Navigate from Team Settings back to Settings page", async () => { + await settingsPage.navigateToSettingsPage(); + }); + + await test.step("Navigate from Settings to Security Settings", async () => { + await settingsPage.navigateToSecuritySettings(); + }); + + await test.step("Navigate from Security Settings to Mining Pools Settings", async () => { + await settingsPage.navigateToMiningPoolsSettings(); + }); + + await test.step("Navigate from Mining Pools Settings to Miners page", async () => { + await settingsPage.navigateToMinersPage(); + }); + + await test.step("Navigate from Miners page back to Mining Pools Settings", async () => { + await settingsPage.navigateToMiningPoolsSettings(); + }); + + await test.step("Navigate from Mining Pools Settings to Home page", async () => { + await settingsPage.navigateToHomePage(); + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/racks.spec.ts b/client/e2eTests/protoFleet/spec/racks.spec.ts new file mode 100644 index 000000000..ef5d6651b --- /dev/null +++ b/client/e2eTests/protoFleet/spec/racks.spec.ts @@ -0,0 +1,578 @@ +import { testConfig } from "../config/test.config"; +import { test } from "../fixtures/pageFixtures"; +import { CommonSteps } from "../helpers/commonSteps"; +import { AuthPage } from "../pages/auth"; +import { MinersPage } from "../pages/miners"; +import { type RackSelectorMiner, RacksPage } from "../pages/racks"; + +const AUTOMATION_ZONE = "AutomationZone"; +const RACK_COLUMNS = 2; +const RACK_ROWS = 2; +const VALIDATION_RACK_COLUMNS = 1; +const VALIDATION_RACK_ROWS = 1; +const NETWORK_RACK_COLUMNS = 9; +const NETWORK_RACK_ROWS = 9; +const LARGE_RACK_COLUMNS = 3; +const LARGE_RACK_ROWS = 3; +const OVERVIEW_RACK_COLUMNS = 8; +const OVERVIEW_RACK_ROWS = 2; +const ORDER_INDEX_SCENARIOS = [ + { label: "Bottom left", expectedNumbers: [3, 4, 1, 2] }, + { label: "Top left", expectedNumbers: [1, 2, 3, 4] }, + { label: "Bottom right", expectedNumbers: [4, 3, 2, 1] }, + { label: "Top right", expectedNumbers: [2, 1, 4, 3] }, +] as const; + +test.describe("Racks", () => { + test.beforeEach(async ({ page, commonSteps, racksPage }) => { + await page.goto("/"); + await commonSteps.loginAsAdmin(); + await racksPage.navigateToRacksPage(); + }); + + test.afterEach("CLEANUP: Delete all racks", async ({ browser }, testInfo) => { + const isMobile = testInfo.project.use?.isMobile ?? false; + const context = await browser.newContext({ + baseURL: testConfig.baseUrl, + viewport: testInfo.project.use?.viewport, + }); + + try { + const page = await context.newPage(); + await page.goto("/"); + + const authPage = new AuthPage(page, isMobile); + const minersPage = new MinersPage(page, isMobile); + const racksPage = new RacksPage(page, isMobile); + const commonSteps = new CommonSteps(authPage, minersPage); + + await commonSteps.loginAsAdmin(); + await racksPage.navigateToRacksPage(); + await cleanupAllRacks(racksPage); + } finally { + await context.close(); + } + }); + + async function cleanupAllRacks(racksPage: RacksPage) { + await racksPage.navigateToRacksPage(); + await racksPage.tryAction(() => racksPage.clickViewList()); + await racksPage.waitForRackListToLoad(); + + let rackNames = await racksPage.listRackNames(); + + while (rackNames.length > 0) { + await racksPage.openRackFromList(rackNames[0]); + await racksPage.clickEditRack(); + await racksPage.clickDeleteRack(); + await racksPage.clickDeleteConfirm(); + await racksPage.tryAction(() => racksPage.validateRackDeletedToast()); + + await racksPage.navigateToRacksPage(); + await racksPage.tryAction(() => racksPage.clickViewList()); + await racksPage.waitForRackListToLoad(); + rackNames = await racksPage.listRackNames(); + } + } + + function createZoneName(prefix: "A" | "B") { + const suffix = Math.random() + .toString(36) + .replace(/[^a-z]+/g, "") + .slice(0, 6); + return `${prefix}-${suffix || "zone"}`; + } + + async function addSelectableMinersToSlots( + racksPage: RacksPage, + minerCount: number, + slotNumbers: readonly number[], + ): Promise { + test.expect(slotNumbers).toHaveLength(minerCount); + + await racksPage.clickAddMiners(); + await racksPage.waitForMinerSelectorListToLoad(); + + const selectableMinerIndexes = await racksPage.getSelectableMinerIndexes(minerCount); + const selectedMiners = await racksPage.getMinersFromSelector(selectableMinerIndexes); + await racksPage.selectMinersInSelectorByIndex(selectableMinerIndexes); + await racksPage.clickContinueInMinerSelector(); + + for (let i = 0; i < selectedMiners.length; i++) { + await racksPage.selectRackMiner(selectedMiners[i].ipAddress); + await racksPage.clickRackSlot(slotNumbers[i]); + } + + return selectedMiners; + } + + async function expectGridRackLabels(racksPage: RacksPage, expectedLabels: string[]) { + await test.expect.poll(async () => await racksPage.getGridRackLabels()).toEqual(expectedLabels); + } + + async function expectListRackLabels(racksPage: RacksPage, expectedLabels: string[]) { + await test.expect.poll(async () => await racksPage.listRackNames()).toEqual(expectedLabels); + } + + test("Create rack with miners assigned by name", async ({ racksPage }) => { + let rackLabel = ""; + let orderIndexValue = ""; + let selectedMiners: RackSelectorMiner[] = []; + + await test.step("Create a new 2x2 rack", async () => { + await racksPage.clickAddRackButton(); + await racksPage.inputZone(AUTOMATION_ZONE); + + rackLabel = await racksPage.getGeneratedRackLabel(); + test.expect(rackLabel).toBeTruthy(); + + await racksPage.enableCustomRackLayout(); + await racksPage.inputColumns(RACK_COLUMNS); + await racksPage.inputRows(RACK_ROWS); + + orderIndexValue = await racksPage.getOrderIndexValue(); + await racksPage.clickContinueFromRackSettings(); + }); + + await test.step("Validate empty rack assignment state", async () => { + await racksPage.validateRackConfiguration(RACK_COLUMNS, RACK_ROWS, orderIndexValue); + await racksPage.validateAssignedMinersCount(0, 4); + }); + + await test.step("Add the first two miners", async () => { + await racksPage.clickAddMiners(); + await racksPage.waitForMinerSelectorListToLoad(); + + selectedMiners = await racksPage.getMinersFromSelector([0, 1]); + test.expect(selectedMiners).toHaveLength(2); + await racksPage.selectMinersInSelectorByIndex([0, 1]); + await racksPage.clickContinueInMinerSelector(); + }); + + await test.step("Assign miners by name and validate positions", async () => { + await racksPage.clickAssignByName(); + await racksPage.validateMinersAssignedByName(selectedMiners); + }); + + await test.step("Save rack and validate rack grid card", async () => { + await racksPage.clickSaveRack(); + await racksPage.validateRackToast(rackLabel); + await racksPage.clickViewGrid(); + await racksPage.validateRackCardVisible(rackLabel, AUTOMATION_ZONE); + await racksPage.validateRackCardGrid(rackLabel, AUTOMATION_ZONE, RACK_COLUMNS, RACK_ROWS); + }); + + await test.step("Validate rack in list view", async () => { + await racksPage.clickViewList(); + await racksPage.waitForRackListToLoad({ allowEmpty: false }); + await racksPage.validateRackRow(rackLabel, AUTOMATION_ZONE, 2); + }); + }); + + test("Rack numbering updates when order index changes", async ({ racksPage }) => { + let selectedMiners: RackSelectorMiner[] = []; + + await test.step("Create a new 2x2 rack", async () => { + await racksPage.clickAddRackButton(); + await racksPage.inputZone(AUTOMATION_ZONE); + await racksPage.enableCustomRackLayout(); + await racksPage.inputColumns(RACK_COLUMNS); + await racksPage.inputRows(RACK_ROWS); + await racksPage.clickContinueFromRackSettings(); + }); + + await test.step("Add four miners", async () => { + await racksPage.clickAddMiners(); + await racksPage.waitForMinerSelectorListToLoad(); + + selectedMiners = await racksPage.getMinersFromSelector([0, 1, 2, 3]); + test.expect(selectedMiners).toHaveLength(4); + await racksPage.selectMinersInSelectorByIndex([0, 1, 2, 3]); + await racksPage.clickContinueInMinerSelector(); + }); + + await test.step("Assign miners manually in DOM order and validate default numbering", async () => { + await racksPage.clickAssignManually(); + await racksPage.assignMinersToSlotsInDomOrder(selectedMiners); + + await racksPage.validateRackSlotNumbersInDomOrder(ORDER_INDEX_SCENARIOS[0].expectedNumbers); + await racksPage.validateMinerPositions(selectedMiners, ORDER_INDEX_SCENARIOS[0].expectedNumbers); + }); + + for (const scenario of ORDER_INDEX_SCENARIOS.slice(1)) { + await test.step(`Change order index to ${scenario.label}`, async () => { + await racksPage.clickEditRackSettings(); + await racksPage.changeOrderIndexAndContinue(scenario.label); + await racksPage.validateRackConfiguration(RACK_COLUMNS, RACK_ROWS, scenario.label); + await racksPage.validateRackSlotNumbersInDomOrder(scenario.expectedNumbers); + await racksPage.validateMinerPositions(selectedMiners, scenario.expectedNumbers); + }); + } + }); + + test("Manual rack assignment supports search, selection replacement, and saved slot state", async ({ racksPage }) => { + let rackLabel = ""; + let selectedMiners: RackSelectorMiner[] = []; + let selectableMinerIndexes: number[] = []; + + await test.step("Create a new 3x3 rack", async () => { + await racksPage.clickAddRackButton(); + await racksPage.inputZone(AUTOMATION_ZONE); + + rackLabel = await racksPage.getGeneratedRackLabel(); + test.expect(rackLabel).toBeTruthy(); + + await racksPage.enableCustomRackLayout(); + await racksPage.inputColumns(LARGE_RACK_COLUMNS); + await racksPage.inputRows(LARGE_RACK_ROWS); + await racksPage.clickContinueFromRackSettings(); + }); + + await test.step("Manage miners and add the first miner to the rack list", async () => { + await racksPage.clickManageMiners(); + await racksPage.waitForMinerSelectorListToLoad(); + + selectableMinerIndexes = await racksPage.getSelectableMinerIndexes(2); + selectedMiners = await racksPage.getMinersFromSelector(selectableMinerIndexes); + test.expect(selectedMiners).toHaveLength(2); + await racksPage.selectMinersInSelectorByIndex([selectableMinerIndexes[0]]); + await racksPage.clickContinueInMinerSelector(); + }); + + await test.step("Search and assign the second miner to slot 04", async () => { + await racksPage.clickRackSlot(4); + await racksPage.clickRackSlotMenuItem("Search miners"); + await racksPage.assignSearchMinerByIpAddress(selectedMiners[1].ipAddress); + + await racksPage.validateMinerRowHasGreenCheck(selectedMiners[1].ipAddress); + await racksPage.validateMinerRowPosition(selectedMiners[1].ipAddress, 4); + await racksPage.validateRackSlotsHighlighted([4]); + }); + + await test.step("Open the assigned slot while the first miner is selected", async () => { + await racksPage.selectRackMiner(selectedMiners[0].ipAddress); + await racksPage.clickRackSlot(4); + + await racksPage.validateMinerRowHasGreenCheck(selectedMiners[1].ipAddress); + await racksPage.validateMinerRowPosition(selectedMiners[1].ipAddress, 4); + await racksPage.validateMinerRowUnassigned(selectedMiners[0].ipAddress); + }); + + await test.step("Replace slot 04 assignment from the list", async () => { + await racksPage.clickRackSlotMenuItem("Select from list"); + await racksPage.selectRackMiner(selectedMiners[0].ipAddress); + + await racksPage.validateMinerRowHasGreenCheck(selectedMiners[0].ipAddress); + await racksPage.validateMinerRowPosition(selectedMiners[0].ipAddress, 4); + await racksPage.validateMinerRowUnassigned(selectedMiners[1].ipAddress); + await racksPage.validateRackSlotsHighlighted([4]); + }); + + await test.step("Assign the second miner to slot 06", async () => { + await racksPage.selectRackMiner(selectedMiners[1].ipAddress); + await racksPage.clickRackSlot(6); + + await racksPage.validateMinerRowHasGreenCheck(selectedMiners[0].ipAddress); + await racksPage.validateMinerRowPosition(selectedMiners[0].ipAddress, 4); + await racksPage.validateMinerRowHasGreenCheck(selectedMiners[1].ipAddress); + await racksPage.validateMinerRowPosition(selectedMiners[1].ipAddress, 6); + await racksPage.validateRackSlotsHighlighted([4, 6]); + }); + + await test.step("Clear assignments and validate empty state", async () => { + await racksPage.clickClearAssignments(); + + await racksPage.validateMinerRowUnassigned(selectedMiners[0].ipAddress); + await racksPage.validateMinerRowUnassigned(selectedMiners[1].ipAddress); + await racksPage.validateRackSlotsNotHighlighted([4, 6]); + }); + + await test.step("Assign miners to slots 01 and 09 and save", async () => { + await racksPage.selectRackMiner(selectedMiners[0].ipAddress); + await racksPage.clickRackSlot(1); + await racksPage.selectRackMiner(selectedMiners[1].ipAddress); + await racksPage.clickRackSlot(9); + + await racksPage.validateMinerRowHasGreenCheck(selectedMiners[0].ipAddress); + await racksPage.validateMinerRowPosition(selectedMiners[0].ipAddress, 1); + await racksPage.validateMinerRowHasGreenCheck(selectedMiners[1].ipAddress); + await racksPage.validateMinerRowPosition(selectedMiners[1].ipAddress, 9); + await racksPage.validateRackSlotsHighlighted([1, 9]); + + await racksPage.clickSaveRack(); + await racksPage.validateRackToast(rackLabel); + }); + + await test.step("Open the created rack and validate saved slots", async () => { + await racksPage.clickViewGrid(); + await racksPage.openRackCard(rackLabel, AUTOMATION_ZONE); + await racksPage.validateRackOverviewAssignedSlots([1, 9]); + await racksPage.validateRackOverviewEmptySlots([2, 3, 4, 5, 6, 7, 8]); + }); + }); + + test("Rack overview search assignment updates slots and miners filter state", async ({ racksPage, minersPage }) => { + let rackLabel = ""; + let selectedMiners: RackSelectorMiner[] = []; + let selectableMinerIndexes: number[] = []; + let expectedVisibleMinerCount = 0; + + await test.step("Create and save a new 8x2 rack", async () => { + await racksPage.clickAddRackButton(); + await racksPage.inputZone(AUTOMATION_ZONE); + + rackLabel = await racksPage.getGeneratedRackLabel(); + test.expect(rackLabel).toBeTruthy(); + + await racksPage.enableCustomRackLayout(); + await racksPage.inputColumns(OVERVIEW_RACK_COLUMNS); + await racksPage.inputRows(OVERVIEW_RACK_ROWS); + await racksPage.clickContinueFromRackSettings(); + await racksPage.clickSaveRack(); + await racksPage.validateRackToast(rackLabel); + }); + + await test.step("Open the created rack and assign the first miner to slot 02", async () => { + await racksPage.clickViewGrid(); + await racksPage.openRackCard(rackLabel, AUTOMATION_ZONE); + await racksPage.clickRackOverviewEmptySlot(2); + await racksPage.waitForMinerSelectorListToLoad(); + expectedVisibleMinerCount = (await racksPage.getAllVisibleMinersFromSelector()).length; + + selectableMinerIndexes = await racksPage.getSelectableMinerIndexes(2); + selectedMiners = await racksPage.getMinersFromSelector(selectableMinerIndexes); + test.expect(selectedMiners).toHaveLength(2); + + await racksPage.assignSearchMinerByIpAddress(selectedMiners[0].ipAddress); + await racksPage.validateRackOverviewAssignedSlots([2]); + }); + + await test.step("Reassign the same first miner from slot 02 to slot 15", async () => { + await racksPage.clickRackOverviewEmptySlot(15); + await racksPage.assignSearchMinerByIpAddress(selectedMiners[0].ipAddress); + + await racksPage.validateRackOverviewAssignedSlots([15]); + await racksPage.validateRackOverviewEmptySlots([2]); + }); + + await test.step("Assign the second miner to slot 02", async () => { + await racksPage.clickRackOverviewEmptySlot(2); + await racksPage.assignSearchMinerByIpAddress(selectedMiners[1].ipAddress); + + await racksPage.validateRackOverviewAssignedSlots([2, 15]); + }); + + await test.step("Validate miners page is filtered to the rack and contains only the assigned miners", async () => { + await racksPage.clickViewMiners(); + await minersPage.validateActiveFilter(rackLabel); + await minersPage.validateAmountOfMiners(2); + await minersPage.validateMinerInList(selectedMiners[0].ipAddress); + await minersPage.validateMinerInList(selectedMiners[1].ipAddress); + }); + + await test.step("Remove all rack miners from edit rack manage miners flow", async () => { + await racksPage.navigateToRacksPage(); + await racksPage.clickViewGrid(); + await racksPage.openRackCard(rackLabel, AUTOMATION_ZONE); + await racksPage.clickEditRack(); + await racksPage.clickManageMiners(); + await racksPage.waitForMinerSelectorListToLoad(); + await racksPage.toggleMinerInSelectorByIpAddress(selectedMiners[0].ipAddress); + await racksPage.toggleMinerInSelectorByIpAddress(selectedMiners[1].ipAddress); + await racksPage.clickContinueInMinerSelector(); + await racksPage.validateTextIsVisible("No miners added to this rack yet."); + await racksPage.clickSaveRack(); + await racksPage.validateRackToast(rackLabel, "updated"); + }); + + await test.step("Validate rack overview is empty after saving", async () => { + await racksPage.validateRackOverviewEmptySlots([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); + }); + + await test.step("Validate miners view empty state and clear the rack filter", async () => { + await racksPage.clickViewMiners(); + await minersPage.validateNoResultsEmptyState(); + await minersPage.clickClearAllFilters(); + await minersPage.validateActiveFilterNotVisible(rackLabel); + await minersPage.waitForMinersListToLoad(); + await minersPage.validateMinersAdded(expectedVisibleMinerCount); + }); + }); + + test("Multiple racks support zone filtering and miner sorting", async ({ racksPage }) => { + const zoneA = createZoneName("A"); + const zoneB = createZoneName("B"); + const createdRackLabels: string[] = []; + + await test.step("Create rack A-01 with three miners", async () => { + await racksPage.clickAddRackButton(); + await racksPage.inputZone(zoneA); + test.expect(await racksPage.getGeneratedRackLabel()).toBe("A-01"); + await racksPage.enableCustomRackLayout(); + await racksPage.inputColumns(RACK_COLUMNS); + await racksPage.inputRows(RACK_ROWS); + await racksPage.clickContinueFromRackSettings(); + await addSelectableMinersToSlots(racksPage, 3, [1, 2, 3]); + await racksPage.clickSaveRack(); + await racksPage.validateRackToast("A-01"); + await racksPage.clickViewGrid(); + await racksPage.validateRackCardVisible("A-01", zoneA); + createdRackLabels.push("A-01"); + }); + + await test.step("Create rack A-02 with two miners", async () => { + await racksPage.clickAddRackButton(); + await racksPage.inputZone(zoneA); + test.expect(await racksPage.getGeneratedRackLabel()).toBe("A-02"); + await racksPage.clickContinueFromRackSettings(); + await addSelectableMinersToSlots(racksPage, 2, [1, 2]); + await racksPage.clickSaveRack(); + await racksPage.validateRackToast("A-02"); + await racksPage.clickViewGrid(); + await racksPage.validateRackCardVisible("A-02", zoneA); + createdRackLabels.push("A-02"); + }); + + await test.step("Create rack B-01 with one miner", async () => { + await racksPage.clickAddRackButton(); + await racksPage.inputZone(zoneB); + test.expect(await racksPage.getGeneratedRackLabel()).toBe("B-01"); + await racksPage.clickContinueFromRackSettings(); + await addSelectableMinersToSlots(racksPage, 1, [1]); + await racksPage.clickSaveRack(); + await racksPage.validateRackToast("B-01"); + await racksPage.clickViewGrid(); + await racksPage.validateRackCardVisible("B-01", zoneB); + createdRackLabels.push("B-01"); + }); + + await test.step("Filter racks by zone in grid view", async () => { + await racksPage.applyZoneFilter([zoneA]); + await expectGridRackLabels(racksPage, ["A-01", "A-02"]); + + await racksPage.applyZoneFilter([zoneB]); + await expectGridRackLabels(racksPage, ["B-01"]); + + await racksPage.toggleAllZoneFilters(); + await expectGridRackLabels(racksPage, createdRackLabels); + + await racksPage.toggleAllZoneFilters(); + }); + + await test.step("Filter racks by zone in list view", async () => { + await racksPage.clickViewList(); + + await racksPage.applyZoneFilter([zoneA]); + await expectListRackLabels(racksPage, ["A-01", "A-02"]); + + await racksPage.applyZoneFilter([zoneB]); + await expectListRackLabels(racksPage, ["B-01"]); + + await racksPage.toggleAllZoneFilters(); + await expectListRackLabels(racksPage, createdRackLabels); + + await racksPage.toggleAllZoneFilters(); + await racksPage.clickViewGrid(); + }); + + await test.step("Validate default grid order and miners sort order", async () => { + await expectGridRackLabels(racksPage, ["A-01", "A-02", "B-01"]); + await racksPage.selectGridSort("Miners"); + await expectGridRackLabels(racksPage, ["B-01", "A-02", "A-01"]); + }); + }); + + test("Assign by network orders all miners by IP address on a 9x9 rack", async ({ racksPage }) => { + let allVisibleMiners: RackSelectorMiner[] = []; + + await test.step("Create a new 9x9 rack and add all visible miners", async () => { + await racksPage.clickAddRackButton(); + await racksPage.inputZone(AUTOMATION_ZONE); + await racksPage.enableCustomRackLayout(); + await racksPage.inputColumns(NETWORK_RACK_COLUMNS); + await racksPage.inputRows(NETWORK_RACK_ROWS); + await racksPage.clickContinueFromRackSettings(); + + await racksPage.clickAddMiners(); + await racksPage.waitForMinerSelectorListToLoad(); + allVisibleMiners = await racksPage.getAllVisibleMinersFromSelector(); + test.expect(allVisibleMiners.length).toBeGreaterThan(0); + test.expect(allVisibleMiners.length).toBeLessThanOrEqual(NETWORK_RACK_COLUMNS * NETWORK_RACK_ROWS); + await racksPage.clickSelectAllMinersInSelector(); + await racksPage.clickContinueInMinerSelector(); + }); + + await test.step("Assign all miners by network and validate positions by IP and name", async () => { + await racksPage.clickAssignByNetwork(); + await racksPage.validateMinersAssignedByNetwork(allVisibleMiners); + }); + }); + + test("Rack settings validation blocks invalid input and miner overflow until corrected", async ({ racksPage }) => { + const validationZone = createZoneName("A"); + let generatedRackLabel = ""; + let selectedMiners: RackSelectorMiner[] = []; + + await test.step("Validate required zone before continuing", async () => { + await racksPage.clickAddRackButton(); + await racksPage.clickContinueFromRackSettings(); + await racksPage.validateRackSettingsFieldError("rack-zone", "A zone is required"); + await racksPage.validateTitleInModal("Rack settings"); + }); + + await test.step("Validate required label and invalid dimensions", async () => { + await racksPage.inputZone(validationZone); + generatedRackLabel = await racksPage.getGeneratedRackLabel(); + test.expect(generatedRackLabel).toBe("A-01"); + + await racksPage.inputRackLabel(""); + await racksPage.enableCustomRackLayout(); + await racksPage.inputColumns(0); + await racksPage.inputRows(13); + await racksPage.clickContinueFromRackSettings(); + + await racksPage.validateRackSettingsFieldError("rack-label", "A label is required"); + await racksPage.validateRackSettingsFieldError("rack-columns", "Columns must be a whole number between 1 and 12"); + await racksPage.validateRackSettingsFieldError("rack-rows", "Rows must be a whole number between 1 and 12"); + await racksPage.validateTitleInModal("Rack settings"); + }); + + await test.step("Correct rack settings and continue", async () => { + await racksPage.inputRackLabel(generatedRackLabel); + await racksPage.inputColumns(VALIDATION_RACK_COLUMNS); + await racksPage.inputRows(VALIDATION_RACK_ROWS); + await racksPage.clickContinueFromRackSettings(); + + await racksPage.validateRackConfiguration(VALIDATION_RACK_COLUMNS, VALIDATION_RACK_ROWS, "Bottom left"); + await racksPage.validateAssignedMinersCount(0, 1); + }); + + await test.step("Validate miner overflow error and recover", async () => { + await racksPage.clickAddMiners(); + await racksPage.waitForMinerSelectorListToLoad(); + + const selectableMinerIndexes = await racksPage.getSelectableMinerIndexes(2); + selectedMiners = await racksPage.getMinersFromSelector(selectableMinerIndexes); + await racksPage.selectMinersInSelectorByIndex(selectableMinerIndexes); + await racksPage.clickContinueInMinerSelector(); + + await racksPage.validateMinerSelectorOverflowError(2, 1); + await racksPage.toggleMinerInSelectorByIpAddress(selectedMiners[1].ipAddress); + await racksPage.clickContinueInMinerSelector(); + }); + + await test.step("Assign remaining miner and save the rack", async () => { + await racksPage.clickAssignByNetwork(); + await racksPage.validateMinersAssignedByNetwork([selectedMiners[0]]); + await racksPage.clickSaveRack(); + await racksPage.validateRackToast(generatedRackLabel); + await racksPage.validateRackCardVisible(generatedRackLabel, validationZone); + await racksPage.validateRackCardGrid( + generatedRackLabel, + validationZone, + VALIDATION_RACK_COLUMNS, + VALIDATION_RACK_ROWS, + ); + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/schedulesSettings.spec.ts b/client/e2eTests/protoFleet/spec/schedulesSettings.spec.ts new file mode 100644 index 000000000..20bbed083 --- /dev/null +++ b/client/e2eTests/protoFleet/spec/schedulesSettings.spec.ts @@ -0,0 +1,132 @@ +/* eslint-disable playwright/expect-expect */ +import { testConfig } from "../config/test.config"; +import { test } from "../fixtures/pageFixtures"; +import { CommonSteps } from "../helpers/commonSteps"; +import { generateRandomText } from "../helpers/testDataHelper"; +import { AuthPage } from "../pages/auth"; +import { MinersPage } from "../pages/miners"; +import { SettingsSchedulesPage } from "../pages/settingsSchedules"; + +const SCHEDULE_PREFIX = "schedule_e2e"; + +test.describe("Proto Fleet - Schedules", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test.afterEach("CLEANUP: Delete schedules created during tests", async ({ browser }, testInfo) => { + const isMobile = testInfo.project.use?.isMobile ?? false; + const viewport = testInfo.project.use?.viewport; + const context = await browser.newContext({ baseURL: testConfig.baseUrl, viewport }); + + try { + const page = await context.newPage(); + await page.goto("/"); + + const authPage = new AuthPage(page, isMobile); + const minersPage = new MinersPage(page, isMobile); + const settingsSchedulesPage = new SettingsSchedulesPage(page, isMobile); + const commonSteps = new CommonSteps(authPage, minersPage); + + await commonSteps.loginAsAdmin(); + await settingsSchedulesPage.navigateToSchedulesSettings(); + await settingsSchedulesPage.deleteSchedulesByPrefix(SCHEDULE_PREFIX); + } finally { + await context.close(); + } + }); + + test("Create, pause/resume, edit, and delete a schedule", async ({ commonSteps, settingsSchedulesPage }) => { + const scheduleName = generateRandomText(SCHEDULE_PREFIX); + const updatedScheduleName = `${scheduleName}_updated`; + + await test.step("Log in as admin", async () => { + await commonSteps.loginAsAdmin(); + }); + + await test.step("Navigate to schedules settings", async () => { + await settingsSchedulesPage.navigateToSchedulesSettings(); + await settingsSchedulesPage.validateSchedulesPageOpened(); + }); + + await test.step("Create a one-time schedule for one miner", async () => { + await settingsSchedulesPage.clickAddSchedule(); + await settingsSchedulesPage.inputScheduleName(scheduleName); + await settingsSchedulesPage.selectStartDate(1); + await settingsSchedulesPage.openMinersTargetSelector(); + await settingsSchedulesPage.waitForMinerSelectionModalToLoad(); + await settingsSchedulesPage.selectFirstMiners(1); + await settingsSchedulesPage.confirmMinerSelection(); + await settingsSchedulesPage.clickSaveSchedule(); + }); + + await test.step("Validate the schedule was created", async () => { + await settingsSchedulesPage.validateScheduleVisible(scheduleName); + await settingsSchedulesPage.validateScheduleStatus(scheduleName, "Active"); + await settingsSchedulesPage.validateScheduleAction(scheduleName, "Set power target"); + await settingsSchedulesPage.validateScheduleTargetSummary(scheduleName, "Applies to 1 miner"); + }); + + await test.step("Pause and resume the schedule", async () => { + await settingsSchedulesPage.pauseSchedule(scheduleName); + await settingsSchedulesPage.validateScheduleStatus(scheduleName, "Paused"); + + await settingsSchedulesPage.resumeSchedule(scheduleName); + await settingsSchedulesPage.validateScheduleStatus(scheduleName, "Active"); + }); + + await test.step("Edit the schedule name", async () => { + await settingsSchedulesPage.openEditSchedule(scheduleName); + await settingsSchedulesPage.inputScheduleName(updatedScheduleName); + await settingsSchedulesPage.clickSaveSchedule(); + await settingsSchedulesPage.validateScheduleVisible(updatedScheduleName); + await settingsSchedulesPage.validateScheduleNotVisible(scheduleName); + }); + + await test.step("Delete the schedule", async () => { + await settingsSchedulesPage.deleteSchedule(updatedScheduleName); + }); + }); + + test("Recurring schedule validation", async ({ commonSteps, settingsSchedulesPage }) => { + const scheduleName = generateRandomText(SCHEDULE_PREFIX); + + await test.step("Log in as admin", async () => { + await commonSteps.loginAsAdmin(); + }); + + await test.step("Navigate to schedules settings", async () => { + await settingsSchedulesPage.navigateToSchedulesSettings(); + await settingsSchedulesPage.validateSchedulesPageOpened(); + }); + + await test.step("Switch to a recurring weekly schedule and validate days are required", async () => { + await settingsSchedulesPage.clickAddSchedule(); + await settingsSchedulesPage.inputScheduleName(scheduleName); + await settingsSchedulesPage.selectScheduleType("Recurring"); + await settingsSchedulesPage.selectScheduleFrequency("Weekly"); + await settingsSchedulesPage.validateSaveDisabled(); + await settingsSchedulesPage.selectWeekday("Monday"); + await settingsSchedulesPage.validateSaveEnabled(); + }); + + await test.step("Validate monthly day-of-month input", async () => { + await settingsSchedulesPage.selectScheduleFrequency("Monthly"); + await settingsSchedulesPage.inputDayOfMonth("0"); + await settingsSchedulesPage.validateValidationMessage("Enter a day between 1 and 31"); + await settingsSchedulesPage.validateSaveDisabled(); + await settingsSchedulesPage.inputDayOfMonth("15"); + await settingsSchedulesPage.validateSaveEnabled(); + }); + + await test.step("Save the recurring schedule", async () => { + await settingsSchedulesPage.clickSaveSchedule(); + }); + + await test.step("Validate the recurring schedule summary", async () => { + await settingsSchedulesPage.validateScheduleVisible(scheduleName); + await settingsSchedulesPage.validateScheduleStatus(scheduleName, "Active"); + await settingsSchedulesPage.validateScheduleSummary(scheduleName, "15th day of month"); + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/securitySettings.spec.ts b/client/e2eTests/protoFleet/spec/securitySettings.spec.ts new file mode 100644 index 000000000..a4073a9c8 --- /dev/null +++ b/client/e2eTests/protoFleet/spec/securitySettings.spec.ts @@ -0,0 +1,147 @@ +/* eslint-disable playwright/expect-expect */ +import { testConfig } from "../config/test.config"; +import { test } from "../fixtures/pageFixtures"; +import { generateRandomText, generateRandomUsername } from "../helpers/testDataHelper"; +import { AuthPage } from "../pages/auth"; +import { SettingsPage } from "../pages/settings"; +import { SettingsSecurityPage } from "../pages/settingsSecurity"; + +test.describe("Proto Fleet - Security Settings", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test.afterAll("CLEANUP: Ensure default admin credentials", async ({ browser }, testInfo) => { + const isMobile = testInfo.project.use?.isMobile ?? false; + const context = await browser.newContext({ baseURL: testConfig.baseUrl }); + try { + const page = await context.newPage(); + const authPage = new AuthPage(page, isMobile); + const settingsPage = new SettingsPage(page, isMobile); + const settingsSecurityPage = new SettingsSecurityPage(page, isMobile); + + const tryLogin = async (candidateUsername: string, candidatePassword: string) => { + await page.goto("/auth"); + await authPage.inputUsername(candidateUsername); + await authPage.inputPassword(candidatePassword); + await authPage.clickLogin(); + + try { + await authPage.validateLoggedIn(3000); + return true; + } catch { + return false; + } + }; + + let loggedIn = await tryLogin(username, password); + + if (!loggedIn) { + // Default credentials failed + loggedIn = await tryLogin(newUsername, password); + + if (loggedIn) { + // Only username needs to be reverted + await settingsPage.navigateToSecuritySettings(); + await settingsSecurityPage.clickUpdateUsername(); + await settingsSecurityPage.inputCurrentPassword(password); + await settingsSecurityPage.clickConfirm(); + await settingsSecurityPage.inputNewUsername(username); + await settingsSecurityPage.clickConfirmUsername(); + await settingsSecurityPage.validateUsernameChangeToast(); + } else { + // Both username and password need to be reverted + loggedIn = await tryLogin(newUsername, newPassword); + if (!loggedIn) { + throw new Error("Unable to log in with updated admin credentials during cleanup."); + } + + await settingsPage.navigateToSecuritySettings(); + await settingsSecurityPage.clickUpdatePassword(); + await settingsSecurityPage.inputCurrentPassword(newPassword); + await settingsSecurityPage.clickConfirm(); + await settingsSecurityPage.inputNewPassword(password); + await settingsSecurityPage.inputConfirmPassword(password); + await settingsSecurityPage.clickConfirmPassword(); + await settingsSecurityPage.validatePasswordChangeToast(); + + await settingsSecurityPage.clickUpdateUsername(); + await settingsSecurityPage.inputCurrentPassword(password); + await settingsSecurityPage.clickConfirm(); + await settingsSecurityPage.inputNewUsername(username); + await settingsSecurityPage.clickConfirmUsername(); + await settingsSecurityPage.validateUsernameChangeToast(); + } + } + } finally { + await context.close(); + } + }); + + const username = testConfig.users.admin.username; + const password = testConfig.users.admin.password; + + const newUsername = generateRandomUsername(); + const newPassword = generateRandomText("A1!"); + + test("Update admin username and password", async ({ authPage, settingsPage, settingsSecurityPage }) => { + await test.step("Log in as admin", async () => { + await authPage.inputUsername(username); + await authPage.inputPassword(password); + await authPage.clickLogin(); + await authPage.validateLoggedIn(); + }); + + await test.step("Navigate to Security Settings", async () => { + await settingsPage.navigateToSecuritySettings(); + }); + + await test.step("Change admin username", async () => { + await settingsSecurityPage.clickUpdateUsername(); + await settingsSecurityPage.inputCurrentPassword(password); + await settingsSecurityPage.clickConfirm(); + await settingsSecurityPage.inputNewUsername(newUsername); + await settingsSecurityPage.clickConfirmUsername(); + await settingsSecurityPage.validateUsernameChangeToast(); + await settingsSecurityPage.validateUsername(newUsername); + }); + + await test.step("Log out", async () => { + await authPage.logout(); + await authPage.validateRedirectedToAuth(); + }); + + await test.step("Log in with new username", async () => { + await authPage.inputUsername(newUsername); + await authPage.inputPassword(password); + await authPage.clickLogin(); + await authPage.validateLoggedIn(); + }); + + await test.step("Navigate to Security Settings", async () => { + await settingsPage.navigateToSecuritySettings(); + }); + + await test.step("Change admin password", async () => { + await settingsSecurityPage.clickUpdatePassword(); + await settingsSecurityPage.inputCurrentPassword(password); + await settingsSecurityPage.clickConfirm(); + await settingsSecurityPage.inputNewPassword(newPassword); + await settingsSecurityPage.inputConfirmPassword(newPassword); + await settingsSecurityPage.clickConfirmPassword(); + await settingsSecurityPage.validatePasswordChangeToast(); + }); + + await test.step("Log out", async () => { + await authPage.logout(); + await authPage.validateRedirectedToAuth(); + }); + + await test.step("Log in with new password", async () => { + await authPage.inputUsername(newUsername); + await authPage.inputPassword(newPassword); + await authPage.clickLogin(); + await authPage.validateLoggedIn(); + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/teamAccounts.spec.ts b/client/e2eTests/protoFleet/spec/teamAccounts.spec.ts new file mode 100644 index 000000000..044c36a72 --- /dev/null +++ b/client/e2eTests/protoFleet/spec/teamAccounts.spec.ts @@ -0,0 +1,245 @@ +/* eslint-disable playwright/expect-expect */ +import { testConfig } from "../config/test.config"; +import { test } from "../fixtures/pageFixtures"; +import { CommonSteps } from "../helpers/commonSteps"; +import { generateRandomUsername } from "../helpers/testDataHelper"; +import { AuthPage } from "../pages/auth"; +import { MinersPage } from "../pages/miners"; +import { SettingsPage } from "../pages/settings"; +import { SettingsTeamPage } from "../pages/settingsTeam"; + +test.describe("Proto Fleet - Team Accounts", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test.afterAll("CLEANUP: Deactivate any team members created during tests", async ({ browser }, testInfo) => { + const isMobile = testInfo.project.use?.isMobile ?? false; + const context = await browser.newContext({ baseURL: testConfig.baseUrl }); + + try { + const page = await context.newPage(); + await page.goto("/"); + + const authPage = new AuthPage(page, isMobile); + const minersPage = new MinersPage(page, isMobile); + const settingsPage = new SettingsPage(page, isMobile); + const settingsTeamPage = new SettingsTeamPage(page, isMobile); + const commonSteps = new CommonSteps(authPage, minersPage); + + await commonSteps.loginAsAdmin(); + + await settingsPage.navigateToTeamSettings(); + await settingsTeamPage.validateTeamSettingsPageOpened(); + await settingsTeamPage.validateMemberVisible("admin"); + + const teamMemberRows = await page.getByTestId("list-row").all(); + const usernamesToDeactivate: string[] = []; + + for (const row of teamMemberRows) { + const usernameElement = row.locator(`//td[@data-testid='username']//span`); + const username = await usernameElement.textContent(); + + const trimmedUsername = username?.trim(); + if (trimmedUsername && trimmedUsername.startsWith("username_")) { + usernamesToDeactivate.push(trimmedUsername); + } + } + + for (const username of usernamesToDeactivate) { + await settingsTeamPage.clickMemberActionsMenu(username); + await settingsTeamPage.clickDeactivate(); + await settingsTeamPage.clickConfirmDeactivation(); + await settingsTeamPage.validateMemberNotInList(username); + } + } finally { + await context.close(); + } + }); + + test("Add team member", async ({ settingsPage, settingsTeamPage, commonSteps }) => { + await test.step("Log in as admin", async () => { + await commonSteps.loginAsAdmin(); + }); + + await test.step("Navigate to Team Settings", async () => { + await settingsPage.navigateToTeamSettings(); + await settingsTeamPage.validateTeamSettingsPageOpened(); + }); + + const username = generateRandomUsername(); + + await test.step("Add a new team member", async () => { + await settingsTeamPage.clickAddTeamMember(); + await settingsTeamPage.inputMemberUsername(username); + await settingsTeamPage.clickSaveTeamMember(); + }); + + await test.step("Validate member was added", async () => { + await settingsTeamPage.validateMemberAdded(); + await settingsTeamPage.validateCopyPasswordButtonVisible(); + await settingsTeamPage.clickDone(); + }); + + await test.step("Validate member appears in list with correct role and login status", async () => { + await settingsTeamPage.validateMemberRole(username, "Admin"); + await settingsTeamPage.validateMemberLastLogin(username, "Never"); + }); + }); + + test("New member log in", async ({ authPage, settingsPage, settingsTeamPage, commonSteps }) => { + let username = generateRandomUsername(); + let tempPassword: string; + + await test.step("Log in as admin and navigate to team settings", async () => { + await commonSteps.loginAsAdmin(); + await settingsPage.navigateToTeamSettings(); + await settingsTeamPage.validateTeamSettingsPageOpened(); + }); + + await test.step("Add a new team member", async () => { + await settingsTeamPage.clickAddTeamMember(); + await settingsTeamPage.inputMemberUsername(username); + await settingsTeamPage.clickSaveTeamMember(); + await settingsTeamPage.validateMemberAdded(); + tempPassword = await settingsTeamPage.getTemporaryPassword(); + await settingsTeamPage.clickDone(); + await settingsTeamPage.validateMemberVisible(username); + }); + + await test.step("Log out as admin", async () => { + await authPage.logout(); + await authPage.validateRedirectedToAuth(); + }); + + await test.step("Log in as new member with temporary password", async () => { + await authPage.inputUsername(username); + await authPage.inputPassword(tempPassword); + await authPage.clickLogin(); + }); + + await test.step("Set new password", async () => { + await authPage.inputNewPassword("Password123!"); + await authPage.inputConfirmPassword("Password123!"); + await authPage.clickContinue(); + await authPage.clickLoginButton(); + await authPage.validateLoggedIn(); + }); + + await test.step("Verify no admin rights", async () => { + await settingsPage.navigateToTeamSettings(); + await settingsTeamPage.validateTeamSettingsPageOpened(); + await settingsTeamPage.validateNoAdminRights(); + }); + }); + + test("New member password reset", async ({ authPage, settingsPage, settingsTeamPage, commonSteps }) => { + let username = generateRandomUsername(); + let tempPassword1: string; + let tempPassword2: string; + + await commonSteps.loginAsAdmin(); + + await test.step("Navigate to team settings", async () => { + await settingsPage.navigateToTeamSettings(); + await settingsTeamPage.validateTeamSettingsPageOpened(); + }); + + await test.step("Add team member", async () => { + await settingsTeamPage.clickAddTeamMember(); + await settingsTeamPage.inputMemberUsername(username); + await settingsTeamPage.clickSaveTeamMember(); + await settingsTeamPage.validateMemberAdded(); + tempPassword1 = await settingsTeamPage.getTemporaryPassword(); + await settingsTeamPage.clickDone(); + }); + + await test.step("Reset member password", async () => { + await settingsTeamPage.clickMemberActionsMenu(username); + await settingsTeamPage.clickResetPassword(); + await settingsTeamPage.clickResetMemberPasswordConfirm(); + await settingsTeamPage.validatePasswordReset(); + tempPassword2 = await settingsTeamPage.getTemporaryPassword(); + await settingsTeamPage.clickDone(); + }); + + await test.step("Log out as admin", async () => { + await authPage.logout(); + await authPage.validateRedirectedToAuth(); + }); + + await test.step("Attempt login with initial (wrong) temp password", async () => { + await authPage.inputUsername(username); + await authPage.clickPasswordVisibilityToggle(); + await authPage.inputPassword(tempPassword1); + await authPage.clickLogin(); + await authPage.validateInvalidCredentials(); + }); + + await test.step("Log in with new temp password", async () => { + await authPage.inputUsername(username); + await authPage.inputPassword(tempPassword2); + await authPage.clickLogin(); + await authPage.validateUpdatePasswordTitle(); + }); + + await test.step("Set new password", async () => { + await authPage.inputNewPassword("Password123!"); + await authPage.inputConfirmPassword("Password123!"); + await authPage.clickContinue(); + await authPage.validatePasswordSaved(); + }); + + await test.step("Complete login", async () => { + await authPage.clickLoginButton(); + await authPage.validateLoggedIn(); + }); + + await test.step("Log out", async () => { + await authPage.logout(); + await authPage.validateRedirectedToAuth(); + }); + }); + + test("Deactivate team member", async ({ authPage, settingsPage, settingsTeamPage, commonSteps }) => { + let username = generateRandomUsername(); + let tempPassword: string; + + await commonSteps.loginAsAdmin(); + + await test.step("Navigate to team settings", async () => { + await settingsPage.navigateToTeamSettings(); + await settingsTeamPage.validateTeamSettingsPageOpened(); + }); + + await test.step("Add team member", async () => { + await settingsTeamPage.clickAddTeamMember(); + await settingsTeamPage.inputMemberUsername(username); + await settingsTeamPage.clickSaveTeamMember(); + await settingsTeamPage.validateMemberAdded(); + tempPassword = await settingsTeamPage.getTemporaryPassword(); + await settingsTeamPage.clickDone(); + }); + + await test.step("Deactivate the newly added team member", async () => { + await settingsTeamPage.clickMemberActionsMenu(username); + await settingsTeamPage.clickDeactivate(); + await settingsTeamPage.clickConfirmDeactivation(); + await settingsTeamPage.validateMemberDeactivatedMessage(username); + await settingsTeamPage.validateMemberNotInList(username); + }); + + await test.step("Log out as admin", async () => { + await authPage.logout(); + await authPage.validateRedirectedToAuth(); + }); + + await test.step("Attempt login with temp password", async () => { + await authPage.inputUsername(username); + await authPage.clickPasswordVisibilityToggle(); + await authPage.inputPassword(tempPassword); + await authPage.clickLogin(); + await authPage.validateInvalidCredentials(); + }); + }); +}); diff --git a/client/e2eTests/protoOS/.gitignore b/client/e2eTests/protoOS/.gitignore new file mode 100644 index 000000000..3bae91a8f --- /dev/null +++ b/client/e2eTests/protoOS/.gitignore @@ -0,0 +1,28 @@ + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ + +# Environment variables +.env +.env.local +.env*.local + +# Local test config (not committed) +config/test.config.local.ts + +# macOS +.DS_Store + +# Editor +.vscode/ +.idea/ + +# Lock files (optional - remove if you want to commit them) +package-lock.json +pnpm-lock.yaml +yarn.lock diff --git a/client/e2eTests/protoOS/README.md b/client/e2eTests/protoOS/README.md new file mode 100644 index 000000000..464090415 --- /dev/null +++ b/client/e2eTests/protoOS/README.md @@ -0,0 +1 @@ +# TODO diff --git a/client/e2eTests/protoOS/config/test.config.defaults.ts b/client/e2eTests/protoOS/config/test.config.defaults.ts new file mode 100644 index 000000000..f8dc5cc07 --- /dev/null +++ b/client/e2eTests/protoOS/config/test.config.defaults.ts @@ -0,0 +1,18 @@ +export const defaultTestConfig = { + baseUrl: "http://localhost:3000", + + admin: { + username: "admin", + password: "Pass123!", + }, + + pool: { + url: "stratum+tcp://mine.ocean.xyz:3334", + }, + + testTimeout: 180000, + actionTimeout: 30000, + interval: 500, +}; + +export type TestConfig = typeof defaultTestConfig; diff --git a/client/e2eTests/protoOS/config/test.config.local.d.ts b/client/e2eTests/protoOS/config/test.config.local.d.ts new file mode 100644 index 000000000..d31079dc8 --- /dev/null +++ b/client/e2eTests/protoOS/config/test.config.local.d.ts @@ -0,0 +1,7 @@ +import type { TestConfig } from "./test.config.defaults"; + +// Declare optional local config module +// This file may not exist (it's gitignored for local development) +declare module "./test.config.local" { + export const localTestConfig: Partial | undefined; +} diff --git a/client/e2eTests/protoOS/config/test.config.local.example.ts b/client/e2eTests/protoOS/config/test.config.local.example.ts new file mode 100644 index 000000000..70295ae5a --- /dev/null +++ b/client/e2eTests/protoOS/config/test.config.local.example.ts @@ -0,0 +1,28 @@ +import type { TestConfig } from "./test.config.defaults"; + +/** + * Local test configuration overrides. + * + * HOW TO USE: + * 1. Copy this file as test.config.local.ts + * 2. Customize values for your local environment + * 3. The .local.ts file is gitignored and won't be committed + * + * You can override any property from the default config. + * Your IDE will provide autocomplete for all available options. + */ +export const localTestConfig: Partial = { + // Uncomment and modify values as needed: + // testTimeout: 60000, + // actionTimeout: 15000, + // interval: 500, + // admin: { + // password: "your-local-admin-password", + // }, + // pool: { + // name: "Your Pool Name", + // url: "stratum+tcp://your-pool.com:3333", + // username: "your-username", + // password: "your-password", + // }, +}; diff --git a/client/e2eTests/protoOS/config/test.config.ts b/client/e2eTests/protoOS/config/test.config.ts new file mode 100644 index 000000000..65748b58e --- /dev/null +++ b/client/e2eTests/protoOS/config/test.config.ts @@ -0,0 +1,24 @@ +import { defaultTestConfig, type TestConfig } from "./test.config.defaults"; + +let localConfig: Partial = {}; +try { + // Try to import local config if it exists (file is gitignored) + // To create: copy test.config.local.example.ts to test.config.local.ts + const module = await import("./test.config.local"); + localConfig = module.localTestConfig || {}; +} catch { + // Local config doesn't exist, use defaults only +} + +// Merge default config with local overrides +export const testConfig: TestConfig = { + ...defaultTestConfig, + ...localConfig, + admin: { + ...defaultTestConfig.admin, + ...localConfig.admin, + }, +}; + +export const DEFAULT_TIMEOUT = testConfig.actionTimeout; +export const DEFAULT_INTERVAL = testConfig.interval; diff --git a/client/e2eTests/protoOS/fixtures/pageFixtures.ts b/client/e2eTests/protoOS/fixtures/pageFixtures.ts new file mode 100644 index 000000000..d6fbf1b30 --- /dev/null +++ b/client/e2eTests/protoOS/fixtures/pageFixtures.ts @@ -0,0 +1,92 @@ +// NOTE: eslint incorrectly identifies 'use' as react hook +/* eslint-disable react-hooks/rules-of-hooks */ +import { test as base } from "@playwright/test"; +import { CommonSteps } from "../helpers/commonSteps"; +import { AuthenticationPage } from "../pages/authentication"; +import { HeaderComponent } from "../pages/components/header"; +import { NavigationComponent } from "../pages/components/navigation"; +import { SleepWakeDialogsComponent } from "../pages/components/sleepWakeDialog"; +import { WakeCalloutComponent } from "../pages/components/wakeCallout"; +import { CoolingPage } from "../pages/cooling"; +import { DiagnosticsPage } from "../pages/diagnostics"; +import { GeneralPage } from "../pages/general"; +import { HardwarePage } from "../pages/hardware"; +import { HomePage } from "../pages/home"; +import { LogsPage } from "../pages/logs"; +import { WelcomePage } from "../pages/onboarding"; +import { PoolsPage } from "../pages/pools"; + +type PageFixtures = { + welcomePage: WelcomePage; + homePage: HomePage; + poolsPage: PoolsPage; + diagnosticsPage: DiagnosticsPage; + logsPage: LogsPage; + authenticationPage: AuthenticationPage; + generalPage: GeneralPage; + hardwarePage: HardwarePage; + coolingPage: CoolingPage; + commonSteps: CommonSteps; + navigationComponent: NavigationComponent; + headerComponent: HeaderComponent; + sleepWakeDialogsComponent: SleepWakeDialogsComponent; + wakeCalloutComponent: WakeCalloutComponent; +}; + +export const test = base.extend({ + welcomePage: async ({ page, isMobile }, use) => { + await use(new WelcomePage(page, isMobile)); + }, + homePage: async ({ page, isMobile }, use) => { + await use(new HomePage(page, isMobile)); + }, + poolsPage: async ({ page, isMobile }, use) => { + await use(new PoolsPage(page, isMobile)); + }, + diagnosticsPage: async ({ page, isMobile }, use) => { + await use(new DiagnosticsPage(page, isMobile)); + }, + logsPage: async ({ page, isMobile }, use) => { + await use(new LogsPage(page, isMobile)); + }, + authenticationPage: async ({ page, isMobile }, use) => { + await use(new AuthenticationPage(page, isMobile)); + }, + generalPage: async ({ page, isMobile }, use) => { + await use(new GeneralPage(page, isMobile)); + }, + hardwarePage: async ({ page, isMobile }, use) => { + await use(new HardwarePage(page, isMobile)); + }, + coolingPage: async ({ page, isMobile }, use) => { + await use(new CoolingPage(page, isMobile)); + }, + navigationComponent: async ({ page, isMobile }, use) => { + await use(new NavigationComponent(page, isMobile)); + }, + headerComponent: async ({ page, isMobile }, use) => { + await use(new HeaderComponent(page, isMobile)); + }, + sleepWakeDialogsComponent: async ({ page, isMobile }, use) => { + await use(new SleepWakeDialogsComponent(page, isMobile)); + }, + wakeCalloutComponent: async ({ page, isMobile }, use) => { + await use(new WakeCalloutComponent(page, isMobile)); + }, + commonSteps: async ( + { welcomePage, navigationComponent, headerComponent, sleepWakeDialogsComponent, wakeCalloutComponent }, + use, + ) => { + await use( + new CommonSteps( + welcomePage, + navigationComponent, + headerComponent, + sleepWakeDialogsComponent, + wakeCalloutComponent, + ), + ); + }, +}); + +export const expect = test.expect; diff --git a/client/e2eTests/protoOS/helpers/commonSteps.ts b/client/e2eTests/protoOS/helpers/commonSteps.ts new file mode 100644 index 000000000..bfec8aa69 --- /dev/null +++ b/client/e2eTests/protoOS/helpers/commonSteps.ts @@ -0,0 +1,98 @@ +import { test } from "@playwright/test"; +import { testConfig } from "../config/test.config"; +import { HeaderComponent } from "../pages/components/header"; +import { NavigationComponent } from "../pages/components/navigation"; +import { SleepWakeDialogsComponent } from "../pages/components/sleepWakeDialog"; +import { WakeCalloutComponent } from "../pages/components/wakeCallout"; +import { WelcomePage } from "../pages/onboarding"; + +export class CommonSteps { + constructor( + private welcomePage: WelcomePage, + private navigationComponent: NavigationComponent, + private headerComponent: HeaderComponent, + private sleepWakeDialogsComponent: SleepWakeDialogsComponent, + private wakeCalloutComponent: WakeCalloutComponent, + ) {} + + async authenticateAsAdmin() { + await test.step("Authenticate as admin", async () => { + await this.welcomePage.inputLoginPassword(testConfig.admin.password); + await this.welcomePage.clickLoginButton(); + await this.welcomePage.validateToastMessage("You are now logged in as admin"); + }); + } + + async navigateToHome() { + await test.step("Navigate to Home", async () => { + await this.navigationComponent.navigateToHome(); + }); + } + + async navigateToDiagnostics() { + await test.step("Navigate to Diagnostics", async () => { + await this.navigationComponent.navigateToDiagnostics(); + }); + } + + async navigateToLogs() { + await test.step("Navigate to Logs", async () => { + await this.navigationComponent.navigateToLogs(); + }); + } + + async navigateToAuthenticationSettings(expand: boolean = true) { + await test.step("Navigate to Authentication settings", async () => { + await this.navigationComponent.navigateToAuthenticationSettings(expand); + }); + } + + async navigateToGeneralSettings(expand: boolean = true) { + await test.step("Navigate to General settings", async () => { + await this.navigationComponent.navigateToGeneralSettings(expand); + }); + } + + async navigateToPoolsSettings(expand: boolean = true) { + await test.step("Navigate to Pools settings", async () => { + await this.navigationComponent.navigateToPoolsSettings(expand); + }); + } + + async navigateToHardwareSettings(expand: boolean = true) { + await test.step("Navigate to Hardware settings", async () => { + await this.navigationComponent.navigateToHardwareSettings(expand); + }); + } + + async navigateToCoolingSettings(expand: boolean = true) { + await test.step("Navigate to Cooling settings", async () => { + await this.navigationComponent.navigateToCoolingSettings(expand); + }); + } + + async validateWakeCallout() { + await test.step(`Validate miner asleep status in current page`, async () => { + await this.wakeCalloutComponent.validateWakeCallout(); + }); + } + + async putMinerToSleep() { + await test.step(`Put miner to sleep from current page`, async () => { + await this.headerComponent.clickPowerButton(); + await this.headerComponent.clickPowerPopoverButton("Sleep"); + await this.sleepWakeDialogsComponent.clickEnterSleepMode(); + await this.sleepWakeDialogsComponent.validateEnteringSleepDialog(); + }); + } + + async wakeMinerFromCallout() { + await test.step(`Wake miner up from current page callout`, async () => { + await this.wakeCalloutComponent.clickWakeMinerInCallout(); + await this.sleepWakeDialogsComponent.clickWakeMinerInDialog(); + await this.sleepWakeDialogsComponent.validateWakingDialog(); + await this.headerComponent.validateMinerStatus("Hashing"); + await this.wakeCalloutComponent.validateWakeCalloutNotVisible(); + }); + } +} diff --git a/client/e2eTests/protoOS/helpers/testDataHelper.ts b/client/e2eTests/protoOS/helpers/testDataHelper.ts new file mode 100644 index 000000000..189744556 --- /dev/null +++ b/client/e2eTests/protoOS/helpers/testDataHelper.ts @@ -0,0 +1,8 @@ +export function generateRandomText(prefix: string): string { + const randomCode = Math.random().toString(36).substring(2, 9); + return `${prefix}_${randomCode}`; +} + +export function generateRandomUsername(): string { + return generateRandomText("username"); +} diff --git a/client/e2eTests/protoOS/pages/authentication.ts b/client/e2eTests/protoOS/pages/authentication.ts new file mode 100644 index 000000000..a03438bbe --- /dev/null +++ b/client/e2eTests/protoOS/pages/authentication.ts @@ -0,0 +1,3 @@ +import { BasePage } from "./base"; + +export class AuthenticationPage extends BasePage {} diff --git a/client/e2eTests/protoOS/pages/base.ts b/client/e2eTests/protoOS/pages/base.ts new file mode 100644 index 000000000..d944717e8 --- /dev/null +++ b/client/e2eTests/protoOS/pages/base.ts @@ -0,0 +1,77 @@ +import { expect, Page } from "@playwright/test"; + +export class BasePage { + constructor( + protected page: Page, + protected isMobile: boolean = false, + ) {} + + async reloadPage() { + await this.page.reload(); + } + + async validateLoggedIn() { + await expect(this.page.getByTestId("power-button")).toBeVisible(); + } + + async validateTitle(expectedTitle: string) { + const titleLocator = this.page.locator(`//*[contains(@class,'heading')][text()="${expectedTitle}"]`); + await expect(titleLocator).toBeVisible(); + } + + async validateTitleInModal(expectedTitle: string) { + const titleLocator = this.page.locator( + `//*[@data-testid='modal']//*[contains(@class,'heading')][text()="${expectedTitle}"]`, + ); + await expect(titleLocator).toBeVisible(); + } + + async validateTitleNotVisible(expectedTitle: string) { + const titleLocator = this.page.locator(`//*[contains(@class,'heading')][text()="${expectedTitle}"]`); + await expect(titleLocator).toBeHidden(); + } + + async validateTextIsVisible(text: string) { + await expect(this.page.getByText(text)).toBeVisible(); + } + + async validateTextInModal(text: string) { + await expect(this.page.getByTestId("modal").getByText(text)).toBeVisible(); + } + + async validateTextNotInModal(text: string) { + await expect(this.page.getByTestId("modal").getByText(text)).toBeHidden(); + } + + async validateToastMessage(message: string) { + await expect(this.page.getByTestId("toast").getByText(message)).toBeVisible(); + } + + async inputLoginPassword(password: string) { + await this.page.getByTestId("password").fill(password); + } + + async clickLoginButton() { + await this.page.getByTestId("login-button").click(); + } + + async clickButton(text: string) { + await this.page.getByRole("button", { name: text, disabled: false }).click(); + } + + async clickIn(text: string, testId: string) { + await this.page.getByTestId(testId).getByRole("button", { name: text, disabled: false }).click(); + } + + async validateModalIsOpen() { + await expect(this.page.getByTestId("modal")).toBeVisible(); + } + + async validateModalIsClosed() { + await expect(this.page.getByTestId("modal")).toBeHidden(); + } + + async validateButtonIsVisible(text: string) { + await expect(this.page.getByRole("button", { name: text })).toBeVisible(); + } +} diff --git a/client/e2eTests/protoOS/pages/components/header.ts b/client/e2eTests/protoOS/pages/components/header.ts new file mode 100644 index 000000000..ef9f34479 --- /dev/null +++ b/client/e2eTests/protoOS/pages/components/header.ts @@ -0,0 +1,23 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "../base"; + +export class HeaderComponent extends BasePage { + async clickPowerButton() { + await this.page.getByTestId("power-button").click(); + } + + async clickPowerPopoverButton(buttonText: string) { + const popover = this.page.getByTestId("power-popover"); + await popover.getByRole("button", { name: buttonText }).click(); + } + + async clickMinerStatusButton(status: string = "Sleeping") { + const header = this.page.getByTestId("page-header"); + await header.getByRole("button", { name: status }).click(); + } + + async validateMinerStatus(status: string) { + const header = this.page.getByTestId("page-header"); + await expect(header.getByRole("button", { name: status })).toBeVisible(); + } +} diff --git a/client/e2eTests/protoOS/pages/components/navigation.ts b/client/e2eTests/protoOS/pages/components/navigation.ts new file mode 100644 index 000000000..c6e20dd60 --- /dev/null +++ b/client/e2eTests/protoOS/pages/components/navigation.ts @@ -0,0 +1,75 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "../base"; + +export class NavigationComponent extends BasePage { + async clickNavigationMenuIfMobile() { + if (this.isMobile) { + await this.page.getByTestId("navigation-menu-button").click(); + } + } + + async clickNavigationItem(itemName: string) { + await this.page.getByTestId("navigation").getByRole("button", { name: itemName }).click(); + } + + async clickNavigationItemInSettings(itemName: string, expand: boolean) { + if (expand) { + await this.clickNavigationItem("Settings"); + } + await this.clickNavigationItem(itemName); + } + + async navigateToHome() { + await this.clickNavigationMenuIfMobile(); + await this.clickNavigationItem("Home"); + await expect(this.page).toHaveURL(/.*\/hashrate/); + await this.validateTitle("Home"); + } + + async navigateToDiagnostics() { + await this.clickNavigationMenuIfMobile(); + await this.clickNavigationItem("Diagnostics"); + await expect(this.page).toHaveURL(/.*\/diagnostics/); + await this.validateTitle("Diagnostics"); + } + + async navigateToLogs() { + await this.clickNavigationMenuIfMobile(); + await this.clickNavigationItem("Logs"); + await expect(this.page).toHaveURL(/.*\/logs/); + } + + async navigateToAuthenticationSettings(expand: boolean) { + await this.clickNavigationMenuIfMobile(); + await this.clickNavigationItemInSettings("Authentication", expand); + await expect(this.page).toHaveURL(/.*\/settings\/authentication/); + await this.validateTitle("Update your admin login"); + } + + async navigateToGeneralSettings(expand: boolean) { + await this.clickNavigationMenuIfMobile(); + await this.clickNavigationItemInSettings("General", expand); + await expect(this.page).toHaveURL(/.*\/settings\/general/); + await this.validateTitle("General"); + } + + async navigateToPoolsSettings(expand: boolean) { + await this.clickNavigationMenuIfMobile(); + await this.clickNavigationItemInSettings("Pools", expand); + await expect(this.page).toHaveURL(/.*\/settings\/mining-pools/); + } + + async navigateToHardwareSettings(expand: boolean) { + await this.clickNavigationMenuIfMobile(); + await this.clickNavigationItemInSettings("Hardware", expand); + await expect(this.page).toHaveURL(/.*\/settings\/hardware/); + await this.validateTitle("Hardware"); + } + + async navigateToCoolingSettings(expand: boolean) { + await this.clickNavigationMenuIfMobile(); + await this.clickNavigationItemInSettings("Cooling", expand); + await expect(this.page).toHaveURL(/.*\/settings\/cooling/); + await this.validateTitle("Cooling"); + } +} diff --git a/client/e2eTests/protoOS/pages/components/sleepWakeDialog.ts b/client/e2eTests/protoOS/pages/components/sleepWakeDialog.ts new file mode 100644 index 000000000..937bce72b --- /dev/null +++ b/client/e2eTests/protoOS/pages/components/sleepWakeDialog.ts @@ -0,0 +1,39 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "../base"; + +export class SleepWakeDialogsComponent extends BasePage { + async clickEnterSleepMode() { + const dialog = this.page.getByTestId("warn-sleep-dialog"); + await dialog.getByRole("button", { name: "Enter sleep mode" }).click(); + } + + async validateEnteringSleepDialog() { + const dialog = this.page.getByTestId("entering-sleep-dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText("Entering sleep mode"); + await expect(dialog).toBeHidden(); + } + + async clickWakeMinerInDialog() { + const dialog = this.page.getByTestId("warn-wake-up-dialog"); + await dialog.getByRole("button", { name: "Wake up miner" }).click(); + } + + async validateWakingDialog() { + const dialog = this.page.getByTestId("waking-dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText("Waking up miner"); + await expect(dialog).toBeHidden(); + } + + async validateMinerAsleepModal() { + await this.validateModalIsOpen(); + await this.validateTitleInModal("Miner is asleep"); + await this.validateTextInModal("Done"); + } + + async clickWakeMinerInModal() { + const modal = this.page.getByTestId("modal"); + await modal.getByRole("button", { name: "Wake miner" }).click(); + } +} diff --git a/client/e2eTests/protoOS/pages/components/wakeCallout.ts b/client/e2eTests/protoOS/pages/components/wakeCallout.ts new file mode 100644 index 000000000..cbebd5a6a --- /dev/null +++ b/client/e2eTests/protoOS/pages/components/wakeCallout.ts @@ -0,0 +1,20 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "../base"; + +export class WakeCalloutComponent extends BasePage { + async validateWakeCallout() { + const callout = this.page.getByTestId("callout"); + await expect(callout.getByText("This miner is asleep and is not hashing.")).toBeVisible(); + await expect(callout.getByRole("button", { name: "Wake up miner" })).toBeVisible(); + } + + async validateWakeCalloutNotVisible() { + const callout = this.page.getByTestId("callout"); + await expect(callout.getByRole("button", { name: "Wake up miner" })).toBeHidden(); + } + + async clickWakeMinerInCallout() { + const callout = this.page.getByTestId("callout"); + await callout.getByRole("button", { name: "Wake up miner" }).click(); + } +} diff --git a/client/e2eTests/protoOS/pages/cooling.ts b/client/e2eTests/protoOS/pages/cooling.ts new file mode 100644 index 000000000..1f78186ff --- /dev/null +++ b/client/e2eTests/protoOS/pages/cooling.ts @@ -0,0 +1,3 @@ +import { BasePage } from "./base"; + +export class CoolingPage extends BasePage {} diff --git a/client/e2eTests/protoOS/pages/diagnostics.ts b/client/e2eTests/protoOS/pages/diagnostics.ts new file mode 100644 index 000000000..dde6ff8a1 --- /dev/null +++ b/client/e2eTests/protoOS/pages/diagnostics.ts @@ -0,0 +1,135 @@ +import { expect } from "@playwright/test"; +import { DEFAULT_INTERVAL, DEFAULT_TIMEOUT } from "../config/test.config"; +import { BasePage } from "./base"; + +export class DiagnosticsPage extends BasePage { + async clickFilterButton(filterName: string) { + await this.page.getByTestId("segmented-control").getByRole("button", { name: filterName }).click(); + } + + private section(sectionTestIdName: string) { + return this.page.getByTestId(`component-section-${sectionTestIdName}`); + } + + async validateAllSectionsVisible(sectionTestIdNames: string[]) { + for (const sectionTestIdName of sectionTestIdNames) { + await expect(this.section(sectionTestIdName)).toBeVisible(); + } + } + + async validateOnlySectionVisible(selectedSectionTestIdName: string, allSectionTestIdNames: string[]) { + await expect(this.section(selectedSectionTestIdName)).toBeVisible(); + + for (const sectionTestIdName of allSectionTestIdNames) { + if (sectionTestIdName === selectedSectionTestIdName) continue; + await expect(this.section(sectionTestIdName)).toBeHidden(); + } + } + + async validateCardCountInSection(sectionTestIdName: string, expectedCardCount: number) { + await expect(this.section(sectionTestIdName).getByTestId("card")).toHaveCount(expectedCardCount); + } + + private cardInSection(sectionTestIdName: string, index: number) { + return this.section(sectionTestIdName).getByTestId("card").nth(index); + } + + async cardHasMoreInfoButton(sectionTestIdName: string, cardIndex: number): Promise { + const card = this.cardInSection(sectionTestIdName, cardIndex); + return (await card.getByRole("button", { name: "More info" }).count()) > 0; + } + + async validateEmptySlotCard(sectionTestIdName: string, cardIndex: number, expectedText: string | RegExp) { + const card = this.cardInSection(sectionTestIdName, cardIndex); + await expect(card.getByText(expectedText)).toBeVisible(); + await expect(card.getByRole("button", { name: "More info" })).toHaveCount(0); + } + + async validateCardInfoOrEmptySlot( + sectionTestIdName: string, + cardIndex: number, + expected: { + metrics: Array<{ label: string | RegExp; valuePattern: RegExp }>; + metadata: Array<{ label: string }>; + emptySlotText: RegExp; + allowExtraMetadataRows?: boolean; + }, + ): Promise<"info" | "empty"> { + const hasMoreInfo = await this.cardHasMoreInfoButton(sectionTestIdName, cardIndex); + + if (!hasMoreInfo) { + await this.validateEmptySlotCard(sectionTestIdName, cardIndex, expected.emptySlotText); + return "empty"; + } + + await this.openMoreInfoForCard(sectionTestIdName, cardIndex); + await this.validateStatusModalMetrics(expected.metrics); + await this.validateStatusModalMetadataRows(expected.metadata, { allowExtraRows: expected.allowExtraMetadataRows }); + await this.closeStatusModal(); + return "info"; + } + + async openMoreInfoForCard(sectionTestIdName: string, cardIndex: number) { + const card = this.cardInSection(sectionTestIdName, cardIndex); + await card.getByRole("button", { name: "More info" }).click(); + await this.validateModalIsOpen(); + } + + async closeStatusModal() { + await this.clickIn("Done", "modal"); + await this.validateModalIsClosed(); + } + + async validateStatusModalMetrics(expected: Array<{ label: string | RegExp; valuePattern: RegExp }>) { + const metrics = this.page.getByTestId("status-modal-metric"); + await expect(metrics).toHaveCount(expected.length); + + for (const { label, valuePattern } of expected) { + const labelLocator = + typeof label === "string" + ? this.page.getByTestId("status-modal-metric-label").getByText(label, { exact: true }) + : this.page.getByTestId("status-modal-metric-label").getByText(label); + + const metric = metrics.filter({ + has: labelLocator, + }); + await expect(metric).toHaveCount(1); + + await expect(metric.first().getByTestId("status-modal-metric-label")).toHaveText(label); + await expect(metric.first().getByTestId("status-modal-metric-value")).toHaveText(valuePattern); + } + } + + async validateStatusModalMetadataRows(expected: Array<{ label: string }>, options?: { allowExtraRows?: boolean }) { + const rows = this.page.getByTestId("status-modal-metadata-row"); + + if (options?.allowExtraRows) { + await expect(async () => { + const count = await rows.count(); + expect(count).toBeGreaterThanOrEqual(expected.length); + }).toPass({ timeout: DEFAULT_TIMEOUT, intervals: [DEFAULT_INTERVAL] }); + } else { + await expect(rows).toHaveCount(expected.length); + } + + for (const { label } of expected) { + const row = rows.filter({ + has: this.page.getByTestId("status-modal-metadata-label").getByText(label, { exact: true }), + }); + await expect(row).toHaveCount(1); + + await expect(row.first().getByTestId("status-modal-metadata-label")).toHaveText(label); + await expect(row.first().getByTestId("status-modal-metadata-value")).toHaveText(/\S+/); + } + } + + async validateTemperaturesInFormat(expectedCount: number, temperaturePattern: RegExp, oppositePattern: RegExp) { + const page = this.page; + const textFields = page.locator("div[class*='text-primary']"); + + await expect(async () => { + await expect(textFields.filter({ hasText: temperaturePattern })).toHaveCount(expectedCount); + await expect(textFields.filter({ hasText: oppositePattern })).toHaveCount(0); + }).toPass({ timeout: DEFAULT_TIMEOUT, intervals: [DEFAULT_INTERVAL] }); + } +} diff --git a/client/e2eTests/protoOS/pages/general.ts b/client/e2eTests/protoOS/pages/general.ts new file mode 100644 index 000000000..7c1edac86 --- /dev/null +++ b/client/e2eTests/protoOS/pages/general.ts @@ -0,0 +1,32 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "./base"; + +export class GeneralPage extends BasePage { + async clickTemperatureButton() { + await this.page.locator('[data-testid="temperature-button"]').click(); + } + + async selectFahrenheit() { + await this.page.locator('//*[@data-testid="fahrenheit-option"]//input').click(); + } + + async selectCelsius() { + await this.page.locator('//*[@data-testid="celsius-option"]//input').click(); + } + + async clickDoneButton() { + await this.clickButton("Done"); + } + + private async validateTemperatureFormat(format: string) { + await expect(this.page.locator('[data-testid="temperature-button"]')).toHaveText(format); + } + + async validateTemperatureFormatFahrenheit() { + await this.validateTemperatureFormat("Fahrenheit"); + } + + async validateTemperatureFormatCelsius() { + await this.validateTemperatureFormat("Celsius"); + } +} diff --git a/client/e2eTests/protoOS/pages/hardware.ts b/client/e2eTests/protoOS/pages/hardware.ts new file mode 100644 index 000000000..6c37207d2 --- /dev/null +++ b/client/e2eTests/protoOS/pages/hardware.ts @@ -0,0 +1,3 @@ +import { BasePage } from "./base"; + +export class HardwarePage extends BasePage {} diff --git a/client/e2eTests/protoOS/pages/home.ts b/client/e2eTests/protoOS/pages/home.ts new file mode 100644 index 000000000..95f84d3b4 --- /dev/null +++ b/client/e2eTests/protoOS/pages/home.ts @@ -0,0 +1,153 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "./base"; + +export class HomePage extends BasePage { + async clickTab(tabName: string) { + await this.page.getByTestId(`tab-${tabName}`).click(); + } + + async validateTabHeading(tabName: string, expectedHeading: string) { + const tab = this.page.getByTestId(`tab-${tabName}`); + await expect(tab.locator('[class*="heading"]').first()).toHaveText(expectedHeading); + } + + async validateTabValue(tabName: string, valuePattern: RegExp) { + const tab = this.page.getByTestId(`tab-${tabName}`); + const valueLocator = tab.locator('[class*="heading"]').nth(1); + await expect(valueLocator).toHaveText(valuePattern); + } + + async validateStatsCount(expectedCount: number) { + const statsItems = this.page.getByTestId("stats-item"); + await expect(statsItems).toHaveCount(expectedCount); + } + + async validateStatItem(index: number, expectedLabel: string, valuePattern: RegExp) { + const statItem = this.page.getByTestId("stats-item").nth(index); + await expect(statItem.locator('[class*="heading"]').first()).toHaveText(expectedLabel); + const valueLocator = statItem.locator('[class*="heading"]').nth(1); + await expect(valueLocator).toHaveText(valuePattern); + } + + async hoverOverChart() { + const chart = this.page.getByTestId("line-chart"); + await chart.scrollIntoViewIfNeeded(); + if (this.isMobile) { + await chart.click(); + } else { + await chart.hover(); + } + } + + async validateChartTooltipWithHashboards(expectedValuePattern: RegExp) { + const tooltip = this.page.locator(".recharts-tooltip-wrapper"); + await expect(tooltip).toBeVisible(); + await expect(tooltip.getByText("Summary")).toBeVisible(); + await expect(tooltip.locator("div[class*='text-primary']").filter({ hasText: expectedValuePattern })).toHaveCount( + 5, + ); + await expect(tooltip.getByText("Hashboards")).toBeVisible(); + } + + async getFilterButtonBackgroundColors() { + const buttons = this.page.locator('[data-testid^="chart-filter-hashboard-"]'); + await expect(buttons).toHaveCount(4); + + const colors: string[] = []; + for (let i = 0; i < 4; i++) { + const btn = buttons.nth(i); + const colorEl = btn.locator('[style*="background"]'); + const style = await colorEl.getAttribute("style"); + const match = style?.match(/rgba?\(.+\)/); + if (match) { + colors.push(match[0]); + } else { + throw new Error(`No background color found for button ${i + 1}`); + } + } + console.warn("Colors: ", colors); + return colors; + } + + async validateFilterButtonBorder(testId: string, shouldBeActive: boolean) { + const btn = this.page.getByTestId(testId); + const classList = await btn.getAttribute("class"); + if (shouldBeActive) { + expect(classList).toContain("border-core-primary-fill"); + } else { + expect(classList).toContain("border-transparent"); + } + } + + async validateAllFilterButtonBorder(expectedHashboards: string[]) { + await this.validateFilterButtonBorder("chart-filter-summary", expectedHashboards.includes("S")); + const allHashboards = ["1", "2", "3", "4"]; + const allActive = allHashboards.every((h) => expectedHashboards.includes(h)); + await this.validateFilterButtonBorder("chart-filter-all-hashboards", allActive); + for (const h of allHashboards) { + await this.validateFilterButtonBorder(`chart-filter-hashboard-${h}`, expectedHashboards.includes(h)); + } + } + + async validateValueInTooltip(expectedHashboards: string[], backgroundColors: string[]) { + const expectedValuePattern = /(\d+,)?\d+\.\d\sTH\/(S|s)/; + const tooltip = this.page.locator(".recharts-tooltip-wrapper"); + await expect(tooltip).toBeVisible(); + + // Summary + if (expectedHashboards.includes("S")) { + const summary = tooltip.getByText("Summary"); + await expect(summary).toBeVisible(); + const summaryValue = summary.locator("xpath=following-sibling::*[1]"); + await expect(summaryValue).toHaveText(expectedValuePattern); + } else { + await expect(tooltip.getByText("Summary")).toBeHidden(); + } + + // Hashboards + const hashboardNumbers = expectedHashboards.filter((h) => ["1", "2", "3", "4"].includes(h)); + if (hashboardNumbers.length > 0) { + const hashboardsLabel = tooltip.getByText("Hashboards"); + await expect(hashboardsLabel).toBeVisible(); + const hashboardElements = tooltip.locator("//div[text()='Hashboards']/following-sibling::*"); + for (const [i, h] of hashboardNumbers.entries()) { + const hashboard = hashboardElements.nth(i); + await expect(hashboard.locator(`//*[text()='${h}']`)).toBeVisible(); + await expect(hashboard).toHaveText(expectedValuePattern); + const colorIndex = parseInt(h) - 1; + const expectedColor = backgroundColors[colorIndex]; + console.warn(`Checking hashboard ${h} with expected color ${expectedColor}`); + await expect(hashboard.locator(`//*[contains(@style, '${expectedColor}')]`)).toBeVisible(); + } + } else { + await expect(tooltip.getByText("Hashboards")).toBeHidden(); + } + } + + async validateFilteredChart(expectedHashboards: string[]) { + const backgroundColors = await this.getFilterButtonBackgroundColors(); + await this.validateAllFilterButtonBorder(expectedHashboards); + await this.hoverOverChart(); + if (!expectedHashboards.length) { + await this.validateValueInTooltip(["S", "1", "2", "3", "4"], backgroundColors); + } else { + await this.validateValueInTooltip(expectedHashboards, backgroundColors); + } + } + + async validateTemperatureInFormat(temperaturePattern: RegExp) { + await this.validateTabValue("temperature", temperaturePattern); + } + + async validateWarnSleepDialog() { + const dialog = this.page.getByTestId("warn-sleep-dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText("Enter sleep mode?"); + } + + async validateWarnWakeUpDialog() { + const dialog = this.page.getByTestId("warn-wake-up-dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText("Wake up miner?"); + } +} diff --git a/client/e2eTests/protoOS/pages/logs.ts b/client/e2eTests/protoOS/pages/logs.ts new file mode 100644 index 000000000..5aea10c5e --- /dev/null +++ b/client/e2eTests/protoOS/pages/logs.ts @@ -0,0 +1,3 @@ +import { BasePage } from "./base"; + +export class LogsPage extends BasePage {} diff --git a/client/e2eTests/protoOS/pages/onboarding.ts b/client/e2eTests/protoOS/pages/onboarding.ts new file mode 100644 index 000000000..f80f783ee --- /dev/null +++ b/client/e2eTests/protoOS/pages/onboarding.ts @@ -0,0 +1,58 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "./base"; + +export class WelcomePage extends BasePage { + async validateWelcomeUrl() { + await expect(this.page).toHaveURL(/.*\/onboarding\/welcome/); + } + + async validateAuthenticationUrl() { + await expect(this.page).toHaveURL(/.*\/onboarding\/authentication/); + } + + async validateVerifyUrl() { + await expect(this.page).toHaveURL(/.*\/onboarding\/verify/); + } + + async inputPassword(password: string) { + await this.page.locator('input[id="password"]').fill(password); + } + + async inputConfirmPassword(password: string) { + await this.page.locator('input[id="confirmPassword"]').fill(password); + } + + async clickContinue() { + await this.clickButton("Continue"); + } + + async validateUsernameFieldDisabledWithValue(expectedValue: string) { + const usernameField = this.page.locator('input[id="username"]'); + await expect(usernameField).toBeDisabled(); + await expect(usernameField).toHaveValue(expectedValue); + } + + async clickGetStartedButton() { + await this.clickButton("Get Started"); + } + + async clickContinueSetup() { + await this.clickButton("Continue setup"); + } + + async validateMiningPoolUrl() { + await expect(this.page).toHaveURL(/.*\/onboarding\/mining-pool/); + } + + async validateDefaultPoolWarningVisible() { + await expect(this.page.getByTestId("warn-default-pool-callout")).toBeVisible(); + } + + async validateDefaultPoolWarningText() { + await this.validateTextIsVisible("A default pool is required to set up your miner."); + } + + async closeDefaultPoolWarning() { + await this.page.getByTestId("warn-default-pool-callout").getByRole("button").click(); + } +} diff --git a/client/e2eTests/protoOS/pages/pools.ts b/client/e2eTests/protoOS/pages/pools.ts new file mode 100644 index 000000000..7c02dda7c --- /dev/null +++ b/client/e2eTests/protoOS/pages/pools.ts @@ -0,0 +1,117 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "./base"; + +export class PoolsPage extends BasePage { + async validatePoolModalOpened() { + await expect(this.page.getByTestId("modal")).toBeVisible(); + } + + async inputPoolName(name: string, poolIndex: number = 0) { + await this.page.getByTestId(`pool-name-${poolIndex}-input`).fill(name); + } + + async inputPoolUrl(url: string, poolIndex: number = 0) { + await this.page.getByTestId(`url-${poolIndex}-input`).fill(url); + } + + async inputPoolUsername(username: string, poolIndex: number = 0) { + await this.page.getByTestId(`username-${poolIndex}-input`).fill(username); + } + + async inputPoolPassword(password: string, poolIndex: number = 0) { + await this.page.getByTestId(`password-${poolIndex}-input`).fill(password); + } + + async clickTestConnection() { + await this.page.locator(`//button//*[text()='Test connection']`).click(); + } + + async validateConnectionSuccessful() { + await expect( + this.page.locator(`//div[@data-testid='pool-connected-callout' and not(contains(@class,'hidden'))]`), + ).toBeVisible(); + } + + async clickSave() { + await this.clickButton("Save"); + } + + async clickAddPool() { + await this.clickButton("Add pool"); + } + + async clickAddAnotherPool() { + await this.clickButton("Add another pool"); + } + + async validateUrlValidationError(poolIndex: number, message: string) { + await expect(this.page.getByTestId(`url-${poolIndex}-input-validation-error`)).toBeVisible(); + await expect(this.page.getByTestId(`url-${poolIndex}-input-validation-error`)).toHaveText(message); + } + + async validateConnectionFailed() { + await expect(this.page.getByTestId("pool-not-connected-callout")).toBeVisible(); + await this.validateTextInModal("We couldn't connect with your pool. Review your pool details and try again."); + } + + async closePoolNotConnectedCallout() { + await this.page.getByTestId("pool-not-connected-callout").getByRole("button").click(); + } + + async validateSaveButtonDisabled() { + await expect(this.page.getByTestId("modal").getByRole("button", { name: "Save" })).toBeDisabled(); + } + + async validateSaveButtonEnabled() { + await expect(this.page.getByTestId("modal").getByRole("button", { name: "Save" })).toBeEnabled(); + } + + async validateCalloutWithText(text: string) { + await expect(this.page.getByTestId("callout")).toBeVisible(); + await expect(this.page.getByTestId("callout").getByText(text)).toBeVisible(); + } + + async closeCallout() { + await this.page.getByTestId("callout").getByRole("button").click(); + } + + async clickMiningPoolButton() { + await this.clickButton("Mining Pool"); + } + + async validatePoolInfoPopoverVisible() { + await expect(this.page.getByTestId("pool-info-popover")).toBeVisible(); + } + + async validateTitleInPopover(title: string) { + await expect( + this.page.getByTestId("pool-info-popover").locator(`//*[contains(@class,'heading')][text()="${title}"]`), + ).toBeVisible(); + } + + async validateTextInPopover(text: string) { + await expect(this.page.getByTestId("pool-info-popover").getByText(text)).toBeVisible(); + } + + async validateExactTextInPopover(text: string) { + await expect(this.page.getByTestId("pool-info-popover").getByText(text, { exact: true })).toBeVisible(); + } + + async clickViewMiningPools() { + await this.page.getByTestId("pool-info-popover").getByRole("button", { name: "View mining pools" }).click(); + } + + async validatePoolRowCount(expectedCount: number) { + const poolRows = this.page.getByTestId("pool-row"); + await expect(poolRows).toHaveCount(expectedCount); + } + + async validatePoolRowDetails(poolIndex: number, poolName: string, poolUrl: string) { + const poolRows = this.page.getByTestId("pool-row"); + const targetRow = poolRows.nth(poolIndex); + + await expect(targetRow).toBeVisible(); + await expect(targetRow.getByText(poolName)).toBeVisible(); + await expect(targetRow.getByTestId(`pool-${poolIndex}-saved-url`)).toHaveText(poolUrl); + } +} diff --git a/client/e2eTests/protoOS/playwright.config.ts b/client/e2eTests/protoOS/playwright.config.ts new file mode 100644 index 000000000..97d3c2b53 --- /dev/null +++ b/client/e2eTests/protoOS/playwright.config.ts @@ -0,0 +1,69 @@ +import { defineConfig } from "@playwright/test"; +import { testConfig } from "./config/test.config"; + +/** + * See https://playwright.dev/docs/test-configuration. + */ + +export default defineConfig({ + testDir: "./spec", + /* Run tests in serial order (one at a time) */ + fullyParallel: false, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: 0, + /* Opt out of parallel tests on CI for more stability */ + workers: 1, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: process.env.CI + ? [ + ["html", { outputFolder: "playwright-report", open: "never" }], + ["github"], + ["junit", { outputFile: "test-results/results.xml" }], + ] + : "html", + /* Global timeout for each test */ + timeout: testConfig.testTimeout, + /* Set default timeout for all expect() assertions */ + expect: { + timeout: testConfig.actionTimeout, + }, + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: testConfig.baseUrl, + + /* Set a consistent viewport size for all tests */ + viewport: { width: 1600, height: 900 }, + + /* Set default timeout for actions like click, fill, etc. */ + actionTimeout: testConfig.actionTimeout, + + /* Capture screenshots (only on failure) and video (retain on failure) so they appear in the HTML report */ + screenshot: "only-on-failure", + video: "retain-on-failure", + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + }, + + // E.g.: npx playwright test --project=desktop + projects: [ + { + name: "desktop", + use: { + viewport: { width: 1600, height: 900 }, + isMobile: false, + }, + }, + // Resolution of the iPhone 14 Pro / 15 Pro / 16 + { + name: "mobile", + use: { + viewport: { width: 393, height: 852 }, + isMobile: true, + }, + }, + ], +}); diff --git a/client/e2eTests/protoOS/spec/00-onboarding.spec.ts b/client/e2eTests/protoOS/spec/00-onboarding.spec.ts new file mode 100644 index 000000000..0739e0e91 --- /dev/null +++ b/client/e2eTests/protoOS/spec/00-onboarding.spec.ts @@ -0,0 +1,83 @@ +/* eslint-disable playwright/expect-expect */ +import { testConfig } from "../config/test.config"; +import { test } from "../fixtures/pageFixtures"; +import { generateRandomText } from "../helpers/testDataHelper"; + +test.describe("Onboarding", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test("Complete onboarding flow @setup", async ({ welcomePage, homePage, poolsPage: poolsPage }) => { + const poolName = generateRandomText("PoolName"); + const poolUsername = generateRandomText("PoolUsername"); + const poolPassword = generateRandomText("PoolPassword"); + + await test.step("Welcome screen - validate and start setup", async () => { + await welcomePage.validateWelcomeUrl(); + await welcomePage.validateTextIsVisible("Miner setup"); + await welcomePage.clickGetStartedButton(); + }); + + await test.step("Verify miner - validate information and continue", async () => { + await welcomePage.validateVerifyUrl(); + await welcomePage.validateTitle("Is this the miner you want to set up?"); + await welcomePage.validateTextIsVisible("Controller Serial"); + await welcomePage.validateTextIsVisible("Mac Address"); + await welcomePage.clickContinueSetup(); + }); + + await test.step("Create authentication - validate and set password", async () => { + await welcomePage.validateAuthenticationUrl(); + await welcomePage.validateTitle("Create an admin login for your miner"); + await welcomePage.validateUsernameFieldDisabledWithValue("admin"); + await welcomePage.inputPassword(testConfig.admin.password); + await welcomePage.inputConfirmPassword(testConfig.admin.password); + await welcomePage.clickContinue(); + }); + + await test.step("Mining pool setup - validate page and warning", async () => { + await welcomePage.validateMiningPoolUrl(); + await welcomePage.validateTitle("Pools"); + await welcomePage.validateTextIsVisible("Add up to 3 pools for your miner."); + await welcomePage.clickButton("Continue"); + await welcomePage.validateDefaultPoolWarningVisible(); + await welcomePage.validateDefaultPoolWarningText(); + await welcomePage.closeDefaultPoolWarning(); + }); + + await test.step("Add default mining pool", async () => { + await poolsPage.clickAddPool(); + await poolsPage.validatePoolModalOpened(); + await poolsPage.inputPoolName(poolName, 0); + await poolsPage.inputPoolUrl(testConfig.pool.url, 0); + await poolsPage.inputPoolUsername(poolUsername, 0); + await poolsPage.inputPoolPassword(poolPassword, 0); + await poolsPage.clickTestConnection(); + await poolsPage.validateConnectionSuccessful(); + await poolsPage.clickSave(); + await poolsPage.validateModalIsClosed(); + }); + + await test.step("Submit one pool", async () => { + await welcomePage.clickButton("Continue"); + }); + + await test.step("Confirm continue without backup pool", async () => { + await welcomePage.validateTitle("Continue without a backup pool?"); + await welcomePage.validateButtonIsVisible("Add a backup pool"); + await welcomePage.clickButton("Continue without backup"); + }); + + await test.step("Your miner is ready", async () => { + await welcomePage.validateTitle("Configuring your miner"); + await welcomePage.validateTitle("Your miner is ready"); + await welcomePage.validateTextIsVisible("Testing your mining pool connections"); + await welcomePage.clickButton("Continue"); + }); + + await test.step("Validate user is logged in to dashboard", async () => { + await homePage.validateLoggedIn(); + }); + }); +}); diff --git a/client/e2eTests/protoOS/spec/dashboard.spec.ts b/client/e2eTests/protoOS/spec/dashboard.spec.ts new file mode 100644 index 000000000..5ed5ad175 --- /dev/null +++ b/client/e2eTests/protoOS/spec/dashboard.spec.ts @@ -0,0 +1,128 @@ +/* eslint-disable playwright/expect-expect */ +import { test } from "../fixtures/pageFixtures"; + +test.describe("Home dashboard", () => { + test.beforeEach(async ({ page, commonSteps }) => { + await page.goto("/"); + await commonSteps.authenticateAsAdmin(); + }); + + test("Validate KPI tabs and stats", async ({ homePage }) => { + await test.step("Validate Hashrate stats", async () => { + const expectedValuePattern = /(\d+,)?\d+\.\d\sTH\/(S|s)/; + await homePage.validateTabHeading("hashrate", "Hashrate"); + await homePage.validateTabValue("hashrate", expectedValuePattern); + await homePage.clickTab("hashrate"); + await homePage.validateStatsCount(4); + await homePage.validateStatItem(0, "Average", expectedValuePattern); + await homePage.validateStatItem(1, "Highest", expectedValuePattern); + await homePage.validateStatItem(2, "Lowest", expectedValuePattern); + await homePage.validateStatItem(3, "Lowest Performer", /Hashboard \d/); + await homePage.hoverOverChart(); + await homePage.validateChartTooltipWithHashboards(expectedValuePattern); + }); + + await test.step("Validate Efficiency stats", async () => { + const expectedValuePattern = /\d+\.\d\sJ\/TH/; + await homePage.validateTabHeading("efficiency", "Efficiency"); + await homePage.validateTabValue("efficiency", expectedValuePattern); + await homePage.clickTab("efficiency"); + await homePage.validateStatsCount(4); + await homePage.validateStatItem(0, "Average", expectedValuePattern); + await homePage.validateStatItem(1, "Highest", expectedValuePattern); + await homePage.validateStatItem(2, "Lowest", expectedValuePattern); + await homePage.validateStatItem(3, "Lowest Performer", /Hashboard \d/); + await homePage.hoverOverChart(); + await homePage.validateChartTooltipWithHashboards(expectedValuePattern); + }); + + await test.step("Validate Power Usage stats", async () => { + const expectedValuePattern = /\d+\.\d\skW/; + const acceptableWattsPattern = /(\d|,)+\.\d\sk?W/; + await homePage.validateTabHeading("powerUsage", "Power Usage"); + await homePage.validateTabValue("powerUsage", expectedValuePattern); + await homePage.clickTab("powerUsage"); + await homePage.validateStatsCount(3); + await homePage.validateStatItem(0, "Average", expectedValuePattern); + await homePage.validateStatItem(1, "Highest", expectedValuePattern); + await homePage.validateStatItem(2, "Lowest", expectedValuePattern); + await homePage.hoverOverChart(); + await homePage.validateChartTooltipWithHashboards(acceptableWattsPattern); + }); + + await test.step("Validate Temperature stats", async () => { + const expectedValuePattern = /\d+\.\d\s°(C|F)/; + await homePage.validateTabHeading("temperature", "Temperature"); + await homePage.validateTabValue("temperature", expectedValuePattern); + await homePage.clickTab("temperature"); + await homePage.validateStatsCount(4); + await homePage.validateStatItem(0, "Average", expectedValuePattern); + await homePage.validateStatItem(1, "Highest", expectedValuePattern); + await homePage.validateStatItem(2, "Lowest", expectedValuePattern); + await homePage.validateStatItem(3, "Hottest Hashboard", /Hashboard \d/); + await homePage.hoverOverChart(); + await homePage.validateChartTooltipWithHashboards(expectedValuePattern); + }); + }); + + test("Chart hashboard filtering", async ({ homePage, page }) => { + await test.step("Initial state: all filters inactive", async () => { + await homePage.validateFilteredChart([]); + }); + + await test.step("Click all hashboards (should enable all hashboards)", async () => { + await page.getByTestId("chart-filter-all-hashboards").click(); + await homePage.validateFilteredChart(["1", "2", "3", "4"]); + }); + + await test.step("Click summary (should enable all)", async () => { + await page.getByTestId("chart-filter-summary").click(); + await homePage.validateFilteredChart(["S", "1", "2", "3", "4"]); + }); + + await test.step("Click hashboard 3 (should leave S,1,2,4)", async () => { + await page.getByTestId("chart-filter-hashboard-3").click(); + await homePage.validateFilteredChart(["S", "1", "2", "4"]); + }); + + await test.step("Click all hashboards (should re-enable all)", async () => { + await page.getByTestId("chart-filter-all-hashboards").click(); + await homePage.validateFilteredChart(["S", "1", "2", "3", "4"]); + }); + + await test.step("Click all hashboards again (should leave only summary)", async () => { + await page.getByTestId("chart-filter-all-hashboards").click(); + await homePage.validateFilteredChart(["S"]); + }); + + await test.step("Click summary (should re-enable all)", async () => { + await page.getByTestId("chart-filter-summary").click(); + await homePage.validateFilteredChart([]); + }); + + await test.step("Click hashboard 4 (should leave only 4)", async () => { + await page.getByTestId("chart-filter-hashboard-4").click(); + await homePage.validateFilteredChart(["4"]); + }); + + await test.step("Click hashboard 2 (should select 2 and 4)", async () => { + await page.getByTestId("chart-filter-hashboard-2").click(); + await homePage.validateFilteredChart(["2", "4"]); + }); + + await test.step("Click summary (should add summary to 2 and 4)", async () => { + await page.getByTestId("chart-filter-summary").click(); + await homePage.validateFilteredChart(["S", "2", "4"]); + }); + + await test.step("Click hashboard 3 (should add 3)", async () => { + await page.getByTestId("chart-filter-hashboard-3").click(); + await homePage.validateFilteredChart(["S", "2", "3", "4"]); + }); + + await test.step("Click hashboard 1 (should add 1, all active)", async () => { + await page.getByTestId("chart-filter-hashboard-1").click(); + await homePage.validateFilteredChart(["S", "1", "2", "3", "4"]); + }); + }); +}); diff --git a/client/e2eTests/protoOS/spec/diagnostics.spec.ts b/client/e2eTests/protoOS/spec/diagnostics.spec.ts new file mode 100644 index 000000000..a34eaeed4 --- /dev/null +++ b/client/e2eTests/protoOS/spec/diagnostics.spec.ts @@ -0,0 +1,123 @@ +/* eslint-disable playwright/expect-expect */ +import { expect } from "@playwright/test"; +import { test } from "../fixtures/pageFixtures"; + +type DiagnosticsSection = { + filterLabel: "Fans" | "Hashboards" | "PSUs" | "Control Board"; + sectionTestIdName: "Fans" | "Hashboards" | "PSU" | "Control Board"; + expectedCardCount: number; +}; + +const diagnosticsSections: DiagnosticsSection[] = [ + { filterLabel: "Fans", sectionTestIdName: "Fans", expectedCardCount: 6 }, + { filterLabel: "Hashboards", sectionTestIdName: "Hashboards", expectedCardCount: 6 }, + // Note: PSU is the only mismatch: filter button shows "PSUs" but the section uses "PSU". + { filterLabel: "PSUs", sectionTestIdName: "PSU", expectedCardCount: 3 }, + { filterLabel: "Control Board", sectionTestIdName: "Control Board", expectedCardCount: 1 }, +]; + +test.describe("Diagnostics", () => { + test.beforeEach(async ({ page, commonSteps }) => { + await page.goto("/"); + await commonSteps.authenticateAsAdmin(); + }); + + test("Diagnostics sections and filters", async ({ commonSteps, diagnosticsPage }) => { + await commonSteps.navigateToDiagnostics(); + + const allSectionTestIdNames = diagnosticsSections.map((s) => s.sectionTestIdName); + + await test.step("Validate all sections are visible", async () => { + await diagnosticsPage.validateAllSectionsVisible(allSectionTestIdNames); + }); + + await test.step("Validate each filter shows only its section and correct card count", async () => { + for (const selected of diagnosticsSections) { + await diagnosticsPage.clickFilterButton(selected.filterLabel); + await diagnosticsPage.validateOnlySectionVisible(selected.sectionTestIdName, allSectionTestIdNames); + await diagnosticsPage.validateCardCountInSection(selected.sectionTestIdName, selected.expectedCardCount); + } + }); + }); + + test("Diagnostics cards show correct modal metrics and metadata", async ({ commonSteps, diagnosticsPage }) => { + await commonSteps.navigateToDiagnostics(); + + const hashRateValuePattern = /\d+(?:\.\d+)?\s*TH\/S/; + const wattsValuePattern = /\d{1,3}(?:,\d{3})*\s*W/; + const celsiusValuePattern = /\d+(?:\.\d+)?\s*°C/; + const efficiencyValuePattern = /\d+(?:\.\d+)?\s*J\/TH/; + const rpmValuePattern = /\d+\s*RPM/; + + const expectedBySection: Record< + DiagnosticsSection["sectionTestIdName"], + { + metrics: Array<{ label: string | RegExp; valuePattern: RegExp }>; + metadata: Array<{ label: string }>; + expectedInfoCardCount: number; + emptySlotText: RegExp; + allowExtraMetadataRows?: boolean; + } + > = { + Fans: { + metrics: [{ label: /\d+(?:\.\d+)?%\s*PWM/, valuePattern: rpmValuePattern }], + metadata: [], + expectedInfoCardCount: 4, + emptySlotText: /No fan detected in this slot/i, + }, + Hashboards: { + metrics: [ + { label: "Hashrate", valuePattern: hashRateValuePattern }, + { label: "Power", valuePattern: wattsValuePattern }, + { label: "ASIC Avg Temp", valuePattern: celsiusValuePattern }, + { label: "ASIC High Temp", valuePattern: celsiusValuePattern }, + { label: "Efficiency", valuePattern: efficiencyValuePattern }, + ], + metadata: [{ label: "Serial Number" }, { label: "Model" }, { label: "ASIC Count" }, { label: "Slot Location" }], + expectedInfoCardCount: 4, + emptySlotText: /No hashboard detected in this slot/i, + allowExtraMetadataRows: true, + }, + PSU: { + metrics: [ + { label: "Input Power", valuePattern: wattsValuePattern }, + { label: "Output Power", valuePattern: wattsValuePattern }, + { label: "Average Temp", valuePattern: celsiusValuePattern }, + { label: "Max Temp", valuePattern: celsiusValuePattern }, + ], + metadata: [{ label: "Serial Number" }, { label: "Model" }, { label: "Firmware Version" }], + expectedInfoCardCount: 2, + emptySlotText: /No psu detected in this slot/i, + }, + "Control Board": { + metrics: [], + metadata: [{ label: "Serial Number" }], + expectedInfoCardCount: 1, + emptySlotText: /No control board detected in this slot/i, + }, + }; + + for (const section of diagnosticsSections) { + await test.step(`Validate modal content for ${section.filterLabel} cards`, async () => { + await diagnosticsPage.clickFilterButton(section.filterLabel); + await diagnosticsPage.validateCardCountInSection(section.sectionTestIdName, section.expectedCardCount); + + const expected = expectedBySection[section.sectionTestIdName]; + + const counts: Record<"info" | "empty", number> = { info: 0, empty: 0 }; + + for (let cardIndex = 0; cardIndex < section.expectedCardCount; cardIndex++) { + const kind = await diagnosticsPage.validateCardInfoOrEmptySlot( + section.sectionTestIdName, + cardIndex, + expected, + ); + counts[kind] += 1; + } + + expect(counts.info).toBe(expected.expectedInfoCardCount); + expect(counts.empty).toBe(section.expectedCardCount - expected.expectedInfoCardCount); + }); + } + }); +}); diff --git a/client/e2eTests/protoOS/spec/pools.spec.ts b/client/e2eTests/protoOS/spec/pools.spec.ts new file mode 100644 index 000000000..6ba2704d2 --- /dev/null +++ b/client/e2eTests/protoOS/spec/pools.spec.ts @@ -0,0 +1,106 @@ +/* eslint-disable playwright/expect-expect */ +import { testConfig } from "../config/test.config"; +import { test } from "../fixtures/pageFixtures"; +import { generateRandomText } from "../helpers/testDataHelper"; + +test.describe("Mining pools", () => { + test.beforeEach(async ({ page, commonSteps }) => { + await page.goto("/"); + await commonSteps.authenticateAsAdmin(); + }); + + test("Check pool errors", async ({ poolsPage: poolsPage, commonSteps }) => { + await commonSteps.navigateToPoolsSettings(); + + await test.step("Open add-pool modal", async () => { + await poolsPage.clickAddAnotherPool(); + }); + + await test.step("Validate URL required for test connection", async () => { + await poolsPage.clickTestConnection(); + await poolsPage.validateUrlValidationError(1, "A Pool URL is required to connect to this pool."); + }); + + await test.step("Test connection fails for invalid pool URL", async () => { + await poolsPage.inputPoolUrl("aaa", 1); + await poolsPage.clickTestConnection(); + await poolsPage.validateConnectionFailed(); + await poolsPage.closePoolNotConnectedCallout(); + }); + + await test.step("Validate save button enable/disable rules", async () => { + await poolsPage.validateSaveButtonDisabled(); + + await poolsPage.inputPoolUsername("aaa", 1); + await poolsPage.validateSaveButtonDisabled(); + + await poolsPage.inputPoolName("aaa", 1); + await poolsPage.validateSaveButtonEnabled(); + + await poolsPage.inputPoolUsername("", 1); + await poolsPage.validateSaveButtonEnabled(); + + await poolsPage.inputPoolUsername("aaa", 1); + await poolsPage.validateSaveButtonEnabled(); + }); + + await test.step("Save invalid pool URL shows error toast", async () => { + await poolsPage.clickSave(); + await poolsPage.validateToastMessage("Your changes were not saved"); + }); + + await commonSteps.navigateToHome(); + await commonSteps.navigateToPoolsSettings(); + + await test.step("Validate the invalid pool was not saved", async () => { + await poolsPage.validatePoolRowCount(1); + }); + }); + + test("Set up backup pools", async ({ poolsPage: poolsPage }) => { + const poolName1 = generateRandomText("PoolName1"); + const poolUsername1 = generateRandomText("PoolUsername1"); + const poolName2 = generateRandomText("PoolName2"); + const poolUsername2 = generateRandomText("PoolUsername2"); + + await test.step("Validate current default pool", async () => { + await poolsPage.clickMiningPoolButton(); + await poolsPage.validatePoolInfoPopoverVisible(); + await poolsPage.validateTitleInPopover("Mining pool"); + await poolsPage.validateExactTextInPopover("Connected"); + await poolsPage.validateTextInPopover("Default Pool"); + await poolsPage.validateTextInPopover(testConfig.pool.url); + await poolsPage.clickViewMiningPools(); + }); + + await test.step("Add first backup pool", async () => { + await poolsPage.clickAddAnotherPool(); + await poolsPage.validatePoolModalOpened(); + await poolsPage.inputPoolName(poolName1, 1); + await poolsPage.inputPoolUrl(testConfig.pool.url, 1); + await poolsPage.inputPoolUsername(poolUsername1, 1); + await poolsPage.clickTestConnection(); + await poolsPage.validateConnectionSuccessful(); + await poolsPage.clickSave(); + await poolsPage.validateModalIsClosed(); + }); + + await test.step("Add second backup pool", async () => { + await poolsPage.clickAddAnotherPool(); + await poolsPage.validatePoolModalOpened(); + await poolsPage.inputPoolName(poolName2, 2); + await poolsPage.inputPoolUrl(testConfig.pool.url, 2); + await poolsPage.inputPoolUsername(poolUsername2, 2); + await poolsPage.clickTestConnection(); + await poolsPage.validateConnectionSuccessful(); + await poolsPage.clickSave(); + await poolsPage.validateModalIsClosed(); + }); + + await test.step("Validate all 3 pool rows exist with correct details", async () => { + await poolsPage.validatePoolRowCount(3); + await poolsPage.validatePoolRowDetails(1, poolName1, testConfig.pool.url); + await poolsPage.validatePoolRowDetails(2, poolName2, testConfig.pool.url); + }); + }); +}); diff --git a/client/e2eTests/protoOS/spec/power.spec.ts b/client/e2eTests/protoOS/spec/power.spec.ts new file mode 100644 index 000000000..a8e288571 --- /dev/null +++ b/client/e2eTests/protoOS/spec/power.spec.ts @@ -0,0 +1,119 @@ +/* eslint-disable playwright/expect-expect */ +import { test } from "../fixtures/pageFixtures"; + +test.describe("Power management", () => { + test.beforeEach(async ({ page, commonSteps }) => { + await page.goto("/"); + await commonSteps.authenticateAsAdmin(); + }); + + test("Miner sleep status in different pages", async ({ + homePage, + commonSteps, + headerComponent, + sleepWakeDialogsComponent, + }) => { + await test.step("Put miner to SLEEP", async () => { + await headerComponent.clickPowerButton(); + await headerComponent.clickPowerPopoverButton("Sleep"); + }); + + await test.step("Confirm enter SLEEP mode", async () => { + await homePage.validateWarnSleepDialog(); + await sleepWakeDialogsComponent.clickEnterSleepMode(); + await sleepWakeDialogsComponent.validateEnteringSleepDialog(); + }); + + await test.step("Validate miner status is Sleeping", async () => { + await headerComponent.validateMinerStatus("Sleeping"); + }); + + await commonSteps.navigateToDiagnostics(); + await commonSteps.validateWakeCallout(); + + await commonSteps.navigateToLogs(); + await commonSteps.validateWakeCallout(); + + await commonSteps.navigateToAuthenticationSettings(); + await commonSteps.validateWakeCallout(); + + await commonSteps.navigateToGeneralSettings(false); + await commonSteps.validateWakeCallout(); + + await commonSteps.navigateToPoolsSettings(false); + await commonSteps.validateWakeCallout(); + + await commonSteps.navigateToHardwareSettings(false); + await commonSteps.validateWakeCallout(); + + await commonSteps.navigateToCoolingSettings(false); + await commonSteps.validateWakeCallout(); + + await commonSteps.navigateToHome(); + + await test.step("Wake miner up", async () => { + await headerComponent.clickPowerButton(); + await headerComponent.clickPowerPopoverButton("Wake up"); + }); + + await test.step("Confirm wake up miner", async () => { + await homePage.validateWarnWakeUpDialog(); + await sleepWakeDialogsComponent.clickWakeMinerInDialog(); + await sleepWakeDialogsComponent.validateWakingDialog(); + }); + + await test.step("Validate miner status is Hashing", async () => { + await headerComponent.validateMinerStatus("Hashing"); + }); + }); + + test("Different ways of setting miner to sleep and waking it up", async ({ + commonSteps, + headerComponent, + sleepWakeDialogsComponent, + }) => { + await test.step("Put miner to sleep from home page", async () => { + await headerComponent.clickPowerButton(); + await headerComponent.clickPowerPopoverButton("Sleep"); + await sleepWakeDialogsComponent.clickEnterSleepMode(); + await sleepWakeDialogsComponent.validateEnteringSleepDialog(); + }); + + await test.step("Wake miner up from header status", async () => { + await headerComponent.clickMinerStatusButton(); + await sleepWakeDialogsComponent.validateMinerAsleepModal(); + await sleepWakeDialogsComponent.clickWakeMinerInModal(); + await sleepWakeDialogsComponent.clickWakeMinerInDialog(); + await sleepWakeDialogsComponent.validateWakingDialog(); + await headerComponent.validateMinerStatus("Hashing"); + }); + + await commonSteps.navigateToDiagnostics(); + await commonSteps.putMinerToSleep(); + await commonSteps.wakeMinerFromCallout(); + + await commonSteps.navigateToLogs(); + await commonSteps.putMinerToSleep(); + await commonSteps.wakeMinerFromCallout(); + + await commonSteps.navigateToAuthenticationSettings(); + await commonSteps.putMinerToSleep(); + await commonSteps.wakeMinerFromCallout(); + + await commonSteps.navigateToGeneralSettings(false); + await commonSteps.putMinerToSleep(); + await commonSteps.wakeMinerFromCallout(); + + await commonSteps.navigateToPoolsSettings(false); + await commonSteps.putMinerToSleep(); + await commonSteps.wakeMinerFromCallout(); + + await commonSteps.navigateToHardwareSettings(false); + await commonSteps.putMinerToSleep(); + await commonSteps.wakeMinerFromCallout(); + + await commonSteps.navigateToCoolingSettings(false); + await commonSteps.putMinerToSleep(); + await commonSteps.wakeMinerFromCallout(); + }); +}); diff --git a/client/e2eTests/protoOS/spec/temperature.spec.ts b/client/e2eTests/protoOS/spec/temperature.spec.ts new file mode 100644 index 000000000..96c467fcd --- /dev/null +++ b/client/e2eTests/protoOS/spec/temperature.spec.ts @@ -0,0 +1,79 @@ +/* eslint-disable playwright/expect-expect */ +import { test } from "../fixtures/pageFixtures"; + +test.describe("Temperature unit switching", () => { + test.beforeEach(async ({ page, commonSteps }) => { + await page.goto("/"); + await commonSteps.authenticateAsAdmin(); + }); + + test("Switch between Fahrenheit and Celsius", async ({ homePage, diagnosticsPage, generalPage, commonSteps }) => { + const fahrenheitPattern = /\d+\.\d\s°F/; + const celsiusPattern = /\d+\.\d\s°C/; + await commonSteps.navigateToGeneralSettings(); + + await test.step("Change temperature to Fahrenheit", async () => { + await generalPage.clickTemperatureButton(); + await generalPage.selectFahrenheit(); + await generalPage.clickDoneButton(); + await generalPage.validateTemperatureFormatFahrenheit(); + }); + + await commonSteps.navigateToHome(); + + await test.step("Validate temperature in Fahrenheit on Home", async () => { + await homePage.clickTab("temperature"); + await homePage.validateTemperatureInFormat(fahrenheitPattern); + await homePage.validateStatItem(0, "Average", fahrenheitPattern); + await homePage.validateStatItem(1, "Highest", fahrenheitPattern); + await homePage.validateStatItem(2, "Lowest", fahrenheitPattern); + await homePage.hoverOverChart(); + await homePage.validateChartTooltipWithHashboards(fahrenheitPattern); + }); + + await commonSteps.navigateToDiagnostics(); + + await test.step("Validate temperature in Fahrenheit on Diagnostics - Hashboards", async () => { + await diagnosticsPage.clickFilterButton("Hashboards"); + await diagnosticsPage.validateTemperaturesInFormat(8, fahrenheitPattern, celsiusPattern); + }); + + await test.step("Validate temperature in Fahrenheit on Diagnostics - PSUs", async () => { + await diagnosticsPage.clickFilterButton("PSUs"); + await diagnosticsPage.validateTemperaturesInFormat(4, fahrenheitPattern, celsiusPattern); + }); + + await commonSteps.navigateToGeneralSettings(); + + await test.step("Change temperature back to Celsius", async () => { + await generalPage.clickTemperatureButton(); + await generalPage.selectCelsius(); + await generalPage.clickDoneButton(); + await generalPage.validateTemperatureFormatCelsius(); + }); + + await commonSteps.navigateToHome(); + + await test.step("Validate temperature in Celsius on Home", async () => { + await homePage.clickTab("temperature"); + await homePage.validateTemperatureInFormat(celsiusPattern); + await homePage.validateStatItem(0, "Average", celsiusPattern); + await homePage.validateStatItem(1, "Highest", celsiusPattern); + await homePage.validateStatItem(2, "Lowest", celsiusPattern); + await homePage.hoverOverChart(); + await homePage.validateChartTooltipWithHashboards(celsiusPattern); + }); + + await commonSteps.navigateToDiagnostics(); + + await test.step("Validate temperature in Celsius on Diagnostics - Hashboards", async () => { + await diagnosticsPage.clickFilterButton("Hashboards"); + await diagnosticsPage.validateTemperaturesInFormat(8, celsiusPattern, fahrenheitPattern); + }); + + await test.step("Validate temperature in Celsius on Diagnostics - PSUs", async () => { + await diagnosticsPage.clickFilterButton("PSUs"); + await diagnosticsPage.validateTemperaturesInFormat(4, celsiusPattern, fahrenheitPattern); + }); + }); +}); diff --git a/client/eslint.config.js b/client/eslint.config.js new file mode 100644 index 000000000..74b64c758 --- /dev/null +++ b/client/eslint.config.js @@ -0,0 +1,178 @@ +import eslint from "@eslint/js"; +import { fixupPluginRules } from "@eslint/compat"; +import typescriptEslint from "@typescript-eslint/eslint-plugin"; +import typescriptEslintParser from "@typescript-eslint/parser"; +import importX from "eslint-plugin-import-x"; +import jsxA11y from "eslint-plugin-jsx-a11y"; +import playwright from "eslint-plugin-playwright"; +import prettier from "eslint-plugin-prettier"; +import eslintConfigPrettier from "eslint-config-prettier"; +import react from "eslint-plugin-react"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import storybook from "eslint-plugin-storybook"; +import globals from "globals"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); // get the resolved path to the file +const __dirname = path.dirname(__filename); // get the name of the directory + +export default [ + // global ignores + { + ignores: ["**/dist/**", "scripts/**", "**/playwright-report/**", "**/test-results/**", "**/api/generated/**"], + }, + eslint.configs.recommended, + { + files: ["**/*.ts", "**/*.tsx"], + languageOptions: { + ecmaVersion: "latest", + globals: { + ...globals.browser, + }, + parser: typescriptEslintParser, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + tsconfigRootDir: __dirname, + }, + }, + plugins: { + "import-x": importX, + "jsx-a11y": jsxA11y, + react, + "react-hooks": fixupPluginRules(reactHooks), + "react-refresh": reactRefresh, + storybook, + "@typescript-eslint": typescriptEslint, + prettier, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + quotes: ["error", "double"], + "no-console": ["error", { allow: ["warn", "error"] }], + "import-x/no-unresolved": "off", + "sort-imports": [ + "error", + { + ignoreCase: true, + ignoreDeclarationSort: true, + }, + ], + "import-x/order": [ + "error", + { + groups: ["external", "builtin", "internal", "parent", "sibling", "index"], + pathGroups: [ + { + pattern: "assets", + group: "internal", + }, + { + pattern: "common", + group: "internal", + }, + { + pattern: "components", + group: "internal", + }, + { + pattern: "motion/react", + group: "external", + position: "before", + }, + { + pattern: "pages", + group: "internal", + }, + { + pattern: "react", + group: "external", + position: "before", + }, + { + pattern: "react-router-dom", + group: "external", + position: "before", + }, + { + pattern: "react-dom/client", + group: "external", + position: "before", + }, + { + pattern: "recharts", + group: "external", + position: "before", + }, + { + pattern: "tailwindcss/resolveConfig", + group: "external", + position: "before", + }, + { + pattern: "clsx", + group: "external", + position: "before", + }, + { + pattern: "@testing-library/react", + group: "external", + position: "before", + }, + { + pattern: "vitest", + group: "external", + position: "before", + }, + ], + pathGroupsExcludedImportTypes: ["internal"], + alphabetize: { + order: "asc", + caseInsensitive: true, + }, + }, + ], + "prettier/prettier": "error", + }, + }, + { + files: ["e2eTests/**/*.ts"], + languageOptions: { + ecmaVersion: "latest", + globals: { + ...globals.node, + }, + parser: typescriptEslintParser, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + }, + }, + plugins: { + "@typescript-eslint": typescriptEslint, + playwright, + prettier, + }, + rules: { + ...typescriptEslint.configs.recommended.rules, + ...playwright.configs["flat/recommended"].rules, + quotes: ["error", "double"], + semi: ["error", "always"], + "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], + "prettier/prettier": "error", + }, + }, + eslintConfigPrettier, +]; diff --git a/client/nfpm-proto-os.yaml b/client/nfpm-proto-os.yaml new file mode 100644 index 000000000..540bf3b11 --- /dev/null +++ b/client/nfpm-proto-os.yaml @@ -0,0 +1,13 @@ +name: proto-os +arch: all +version: "${VERSION}" +maintainer: "Block Inc." +description: "ProtoOS web dashboard" +contents: + - src: dist/protoOS/ + dst: /var/www/ + type: tree + file_info: + mode: 0755 + - src: dist/web_dashboard_version + dst: /etc/web_dashboard_version diff --git a/client/nginx.runner-protofleet.conf b/client/nginx.runner-protofleet.conf new file mode 100644 index 000000000..18e8c6d65 --- /dev/null +++ b/client/nginx.runner-protofleet.conf @@ -0,0 +1,21 @@ +server { + listen 127.0.0.1:8080; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + # Match Vite proxy behavior used by ProtoFleet. + location /api-proxy/ { + proxy_pass http://127.0.0.1:4000/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + client_max_body_size 64m; + } +} diff --git a/client/package-lock.json b/client/package-lock.json new file mode 100644 index 000000000..0a20a4514 --- /dev/null +++ b/client/package-lock.json @@ -0,0 +1,10922 @@ +{ + "name": "fleet-client", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fleet-client", + "version": "0.0.0", + "dependencies": { + "@bufbuild/protobuf": "2.11.0", + "@connectrpc/connect": "2.1.1", + "@connectrpc/connect-web": "2.1.1", + "@dnd-kit/core": "6.3.1", + "@dnd-kit/sortable": "10.0.0", + "clsx": "2.1.1", + "immer": "11.1.4", + "motion": "12.38.0", + "path": "0.12.7", + "react": "19.2.5", + "react-dom": "19.2.5", + "react-router-dom": "7.14.1", + "recharts": "3.8.1", + "zustand": "5.0.12" + }, + "devDependencies": { + "@bufbuild/buf": "1.68.1", + "@bufbuild/protoc-gen-es": "2.11.0", + "@eslint/compat": "2.0.5", + "@eslint/js": "10.0.1", + "@playwright/test": "1.59.1", + "@storybook/addon-docs": "10.3.5", + "@storybook/react": "10.3.5", + "@storybook/react-vite": "10.3.5", + "@tailwindcss/postcss": "4.2.2", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/react": "16.3.2", + "@testing-library/user-event": "14.6.1", + "@types/node": "25.6.0", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "@typescript-eslint/eslint-plugin": "8.58.2", + "@typescript-eslint/parser": "8.58.2", + "@vitejs/plugin-react": "6.0.1", + "chromatic": "16.3.0", + "concurrently": "9.2.1", + "eslint": "10.2.0", + "eslint-config-prettier": "10.1.8", + "eslint-plugin-import-x": "4.16.2", + "eslint-plugin-jsx-a11y": "6.10.2", + "eslint-plugin-playwright": "2.10.1", + "eslint-plugin-prettier": "5.5.5", + "eslint-plugin-react": "7.37.5", + "eslint-plugin-react-hooks": "7.0.1", + "eslint-plugin-react-refresh": "0.5.2", + "eslint-plugin-storybook": "10.3.5", + "globals": "17.5.0", + "image-size": "2.0.2", + "jsdom": "29.0.2", + "postcss": "8.5.10", + "prettier": "3.8.3", + "prettier-plugin-tailwindcss": "0.7.2", + "storybook": "10.3.5", + "swagger-typescript-api": "13.6.10", + "tailwindcss": "4.2.2", + "tsx": "4.21.0", + "typescript": "6.0.2", + "vite": "8.0.8", + "vite-plugin-checker": "0.13.0", + "vitest": "4.1.4" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz", + "integrity": "sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-12.1.0.tgz", + "integrity": "sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "14.0.1", + "@apidevtools/openapi-schemas": "^2.1.0", + "@apidevtools/swagger-methods": "^3.0.2", + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "call-me-maybe": "^1.0.2" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.9.tgz", + "integrity": "sha512-zd9c/Wdso6v1U7v6w3i/hbAr4K7NaSHImdpvmLt+Y9ea5BhilnIGNkfhOJ7FEIuPipAnE9tZeDOll05WDT0kgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.9.tgz", + "integrity": "sha512-r3ElRr7y8ucyN2KdICwGsmj19RoN13CLCa/pvGydghWK6ZzeKQ+TcDjVdtEZz2ElpndM5jXw//B9CEee0mWnVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@biomejs/js-api": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@biomejs/js-api/-/js-api-4.0.0.tgz", + "integrity": "sha512-EOArR/6drRzM1/hwOIz1pZw90FL31Ud4Y7hEHGWVtMNmAwS9SrwZ8hMENGlLVXCeGW/kL46p8kX7eO6x9Nmezg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "@biomejs/wasm-bundler": "^2.3.0", + "@biomejs/wasm-nodejs": "^2.3.0", + "@biomejs/wasm-web": "^2.3.0" + }, + "peerDependenciesMeta": { + "@biomejs/wasm-bundler": { + "optional": true + }, + "@biomejs/wasm-nodejs": { + "optional": true + }, + "@biomejs/wasm-web": { + "optional": true + } + } + }, + "node_modules/@biomejs/wasm-nodejs": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/@biomejs/wasm-nodejs/-/wasm-nodejs-2.4.12.tgz", + "integrity": "sha512-3SGczq2LKHJw9TYhJEmNwgBY767wSu+nRZVkp+oDiczYn2u7Xekb4smn/r3KJpaxGKkxxtTV4h6RTwBUKzf8Fw==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@bufbuild/buf": { + "version": "1.68.1", + "resolved": "https://registry.npmjs.org/@bufbuild/buf/-/buf-1.68.1.tgz", + "integrity": "sha512-QDJ3oy4qZ5EVS2JYtmpE1n9FuaoABthxIddXB050huGddatr1sjHJSSAXXpLotOI18pW3KQ4zzU1x5Ms+pEEOw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "buf": "bin/buf", + "protoc-gen-buf-breaking": "bin/protoc-gen-buf-breaking", + "protoc-gen-buf-lint": "bin/protoc-gen-buf-lint" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@bufbuild/buf-darwin-arm64": "1.68.1", + "@bufbuild/buf-darwin-x64": "1.68.1", + "@bufbuild/buf-linux-aarch64": "1.68.1", + "@bufbuild/buf-linux-armv7": "1.68.1", + "@bufbuild/buf-linux-x64": "1.68.1", + "@bufbuild/buf-win32-arm64": "1.68.1", + "@bufbuild/buf-win32-x64": "1.68.1" + } + }, + "node_modules/@bufbuild/buf-darwin-arm64": { + "version": "1.68.1", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-arm64/-/buf-darwin-arm64-1.68.1.tgz", + "integrity": "sha512-+Cu/2Kr6Add3s+Zk/edcF9QdpnrsukQkdR/z4fk4+qr6YZqfWfiV8f+s14I3h7qPrPnGeCeynvmZ9NmJ1BMYuA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-darwin-x64": { + "version": "1.68.1", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-x64/-/buf-darwin-x64-1.68.1.tgz", + "integrity": "sha512-hvAs452aJ6io9hZKSfr3TvC+//16zW5y5u3ucsIXVkl5mkmKWSCkPbZwGpjNCfRGGUsyRJGL6rixxTgLKw2k5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-linux-aarch64": { + "version": "1.68.1", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-aarch64/-/buf-linux-aarch64-1.68.1.tgz", + "integrity": "sha512-GLCakHzZVKUPlAiJEPGMBLW+yBk8tuz6NNcoeQU5lB5AO7ks8V8x9cy4CQjne4YSl3niF1JtvAQckLKhEWPueQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-linux-armv7": { + "version": "1.68.1", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-armv7/-/buf-linux-armv7-1.68.1.tgz", + "integrity": "sha512-lUMCULl3MOYQe0oAPWnqNVYy8pL+F3Jeq6C4sSY+0E9udaACMc2mZ32gYingaMop9O1qS58HjJbezFxxL+CJqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-linux-x64": { + "version": "1.68.1", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-x64/-/buf-linux-x64-1.68.1.tgz", + "integrity": "sha512-eRU3UWiZQthAgx+qFTG3EeJ/VeOcZzAkKYGt5ansOnOIJHBm+3RG2KqA+Jm8q3EFqB1XpVcGxPXnIu/qmFJXaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-win32-arm64": { + "version": "1.68.1", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-arm64/-/buf-win32-arm64-1.68.1.tgz", + "integrity": "sha512-v3xlKzs3l2C+mYv+T0sYol05DTmsFKYmM5Vz8+AyrXdjxRwq2QH7m0arVWwxHX2MwyhQxKA+qqjoF8bCUM7xxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-win32-x64": { + "version": "1.68.1", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-x64/-/buf-win32-x64-1.68.1.tgz", + "integrity": "sha512-b62pwu+G7n5tF8n1QIoT85K7xgKJZS8SzdN020weOa7IVvMNHCDqMq7nrkz46fXCkK7MtD1YJ6sUp86sWSZPsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", + "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@bufbuild/protoc-gen-es": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protoc-gen-es/-/protoc-gen-es-2.11.0.tgz", + "integrity": "sha512-VzQuwEQDXipbZ1soWUuAWm1Z0C3B/IDWGeysnbX6ogJ6As91C2mdvAND/ekQ4YIWgen4d5nqLfIBOWLqCCjYUA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "2.11.0", + "@bufbuild/protoplugin": "2.11.0" + }, + "bin": { + "protoc-gen-es": "bin/protoc-gen-es" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@bufbuild/protobuf": "2.11.0" + }, + "peerDependenciesMeta": { + "@bufbuild/protobuf": { + "optional": true + } + } + }, + "node_modules/@bufbuild/protoplugin": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protoplugin/-/protoplugin-2.11.0.tgz", + "integrity": "sha512-lyZVNFUHArIOt4W0+dwYBe5GBwbKzbOy8ObaloEqsw9Mmiwv2O48TwddDoHN4itylC+BaEGqFdI1W8WQt2vWJQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "2.11.0", + "@typescript/vfs": "^1.6.2", + "typescript": "5.4.5" + } + }, + "node_modules/@connectrpc/connect": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.1.1.tgz", + "integrity": "sha512-JzhkaTvM73m2K1URT6tv53k2RwngSmCXLZJgK580qNQOXRzZRR/BCMfZw3h+90JpnG6XksP5bYT+cz0rpUzUWQ==", + "license": "Apache-2.0", + "peerDependencies": { + "@bufbuild/protobuf": "^2.7.0" + } + }, + "node_modules/@connectrpc/connect-web": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@connectrpc/connect-web/-/connect-web-2.1.1.tgz", + "integrity": "sha512-J8317Q2MaFRCT1jzVR1o06bZhDIBmU0UAzWx6xOIXzOq8+k71/+k7MUF7AwcBUX+34WIvbm5syRgC5HXQA8fOg==", + "license": "Apache-2.0", + "peerDependencies": { + "@bufbuild/protobuf": "^2.7.0", + "@connectrpc/connect": "2.1.1" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", + "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/compat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.0.5.tgz", + "integrity": "sha512-IbHDbHJfkVNv6xjlET8AIVo/K1NQt7YT4Rp6ok/clyBGcpRx1l6gv0Rq3vBvYfPJIZt6ODf66Zq08FJNDpnzgg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "peerDependencies": { + "eslint": "^8.40 || 9 || 10" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@exodus/schemasafe": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@exodus/schemasafe/-/schemasafe-1.3.0.tgz", + "integrity": "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.7.0.tgz", + "integrity": "sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^13.0.1", + "react-docgen-typescript": "^2.2.2" + }, + "peerDependencies": { + "typescript": ">= 4.3.x", + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mdx-js/react": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", + "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdx": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=16", + "react": ">=16" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@package-json/types": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@package-json/types/-/types-0.0.12.tgz", + "integrity": "sha512-uu43FGU34B5VM9mCNjXCwLaGHYjXdNincqKLaraaCW+7S2+SmiBg1Nv8bPnmschrIfZmfKNY9f3fC376MRrObw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@storybook/addon-docs": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.3.5.tgz", + "integrity": "sha512-WuHbxia/o5TX4Rg/IFD0641K5qId/Nk0dxhmAUNoFs5L0+yfZUwh65XOBbzXqrkYmYmcVID4v7cgDRmzstQNkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mdx-js/react": "^3.0.0", + "@storybook/csf-plugin": "10.3.5", + "@storybook/icons": "^2.0.1", + "@storybook/react-dom-shim": "10.3.5", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^10.3.5" + } + }, + "node_modules/@storybook/builder-vite": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.3.5.tgz", + "integrity": "sha512-i4KwCOKbhtlbQIbhm53+Kk7bMnxa0cwTn1pxmtA/x5wm1Qu7FrrBQV0V0DNjkUqzcSKo1CjspASJV/HlY0zYlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/csf-plugin": "10.3.5", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^10.3.5", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@storybook/csf-plugin": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.3.5.tgz", + "integrity": "sha512-qlEzNKxOjq86pvrbuMwiGD/bylnsXk1dg7ve0j77YFjEEchqtl7qTlrXvFdNaLA89GhW6D/EV6eOCu/eobPDgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "unplugin": "^2.3.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "esbuild": "*", + "rollup": "*", + "storybook": "^10.3.5", + "vite": "*", + "webpack": "*" + }, + "peerDependenciesMeta": { + "esbuild": { + "optional": true + }, + "rollup": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/@storybook/global": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@storybook/global/-/global-5.0.0.tgz", + "integrity": "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@storybook/icons": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-2.0.1.tgz", + "integrity": "sha512-/smVjw88yK3CKsiuR71vNgWQ9+NuY2L+e8X7IMrFjexjm6ZR8ULrV2DRkTA61aV6ryefslzHEGDInGpnNeIocg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@storybook/react": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.3.5.tgz", + "integrity": "sha512-tpLTLaVGoA6fLK3ReyGzZUricq7lyPaV2hLPpj5wqdXLV/LpRtAHClUpNoPDYSBjlnSjL81hMZijbkGC3mA+gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@storybook/react-dom-shim": "10.3.5", + "react-docgen": "^8.0.2", + "react-docgen-typescript": "^2.2.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "storybook": "^10.3.5", + "typescript": ">= 4.9.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@storybook/react-dom-shim": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.3.5.tgz", + "integrity": "sha512-Gw8R7XZm0zSUH0XAuxlQJhmizsLzyD6x00KOlP6l7oW9eQHXGfxg3seNDG3WrSAcW07iP1/P422kuiriQlOv7g==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "storybook": "^10.3.5" + } + }, + "node_modules/@storybook/react-vite": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.3.5.tgz", + "integrity": "sha512-UB5sJHeh26bfd8sNMx2YPGYRYmErIdTRaLOT28m4bykQIa1l9IgVktsYg/geW7KsJU0lXd3oTbnUjLD+enpi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0", + "@rollup/pluginutils": "^5.0.2", + "@storybook/builder-vite": "10.3.5", + "@storybook/react": "10.3.5", + "empathic": "^2.0.0", + "magic-string": "^0.30.0", + "react-docgen": "^8.0.0", + "resolve": "^1.22.8", + "tsconfig-paths": "^4.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "storybook": "^10.3.5", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz", + "integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "postcss": "^8.5.6", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/doctrine": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@types/doctrine/-/doctrine-0.0.9.tgz", + "integrity": "sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mdx": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", + "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/resolve": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz", + "integrity": "sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/swagger-schema-official": { + "version": "2.0.25", + "resolved": "https://registry.npmjs.org/@types/swagger-schema-official/-/swagger-schema-official-2.0.25.tgz", + "integrity": "sha512-T92Xav+Gf/Ik1uPW581nA+JftmjWPgskw/WBf4TJzxRG/SJ+DfNnNE+WuZ4mrXuzflQMqMkm1LSYjzYW7MB1Cg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", + "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/type-utils": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz", + "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", + "integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.2", + "@typescript-eslint/types": "^8.58.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz", + "integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", + "integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz", + "integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", + "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", + "integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.2", + "@typescript-eslint/tsconfig-utils": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz", + "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", + "integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript/vfs": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz", + "integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3" + }, + "peerDependencies": { + "typescript": "*" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/@vitest/spy": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.4", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@webcontainer/env": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@webcontainer/env/-/env-1.1.1.tgz", + "integrity": "sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.2.tgz", + "integrity": "sha512-byD6KPdvo72y/wj2T/4zGEvvlis+PsZsn/yPS3pEO+sFpcrqRpX/TJCxvVaEsNeMrfQbCr7w163YqoD9IYwHXw==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.17", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz", + "integrity": "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/c12": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", + "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "confbox": "^0.2.4", + "defu": "^6.1.6", + "dotenv": "^17.3.1", + "exsolve": "^1.0.8", + "giget": "^3.2.0", + "jiti": "^2.6.1", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^2.1.0", + "pkg-types": "^2.3.0", + "rc9": "^3.0.1" + }, + "peerDependencies": { + "magicast": "*" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001787", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", + "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chromatic": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/chromatic/-/chromatic-16.3.0.tgz", + "integrity": "sha512-PvXUpXP3l8p2NxLEYhMgo4kGNNBQgCgbslMu+O4Bb37ujiiDWk8QExX/Jk7JeHUewNgK2wg98rtw3gdfz3U+Kg==", + "dev": true, + "license": "MIT", + "bin": { + "chroma": "dist/bin.js", + "chromatic": "dist/bin.js", + "chromatic-cli": "dist/bin.js" + }, + "peerDependencies": { + "@chromatic-com/cypress": "^0.*.* || ^1.0.0", + "@chromatic-com/playwright": "^0.*.* || ^1.0.0", + "@chromatic-com/vitest": "^0.*.* || ^1.0.0" + }, + "peerDependenciesMeta": { + "@chromatic-com/cypress": { + "optional": true + }, + "@chromatic-com/playwright": { + "optional": true + }, + "@chromatic-com/vitest": { + "optional": true + } + } + }, + "node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/comment-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.6.tgz", + "integrity": "sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dompurify": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz", + "integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==", + "dev": true, + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dotenv": { + "version": "17.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", + "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.334", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz", + "integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.1.tgz", + "integrity": "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/es6-promise": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz", + "integrity": "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.0.tgz", + "integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.4", + "@eslint/config-helpers": "^0.5.4", + "@eslint/core": "^1.2.0", + "@eslint/plugin-kit": "^0.7.0", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-import-context": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz", + "integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-tsconfig": "^4.10.1", + "stable-hash-x": "^0.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-context" + }, + "peerDependencies": { + "unrs-resolver": "^1.0.0" + }, + "peerDependenciesMeta": { + "unrs-resolver": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-import-x": { + "version": "4.16.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-import-x/-/eslint-plugin-import-x-4.16.2.tgz", + "integrity": "sha512-rM9K8UBHcWKpzQzStn1YRN2T5NvdeIfSVoKu/lKF41znQXHAUcBbYXe5wd6GNjZjTrP7viQ49n1D83x/2gYgIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@package-json/types": "^0.0.12", + "@typescript-eslint/types": "^8.56.0", + "comment-parser": "^1.4.1", + "debug": "^4.4.1", + "eslint-import-context": "^0.1.9", + "is-glob": "^4.0.3", + "minimatch": "^9.0.3 || ^10.1.2", + "semver": "^7.7.2", + "stable-hash-x": "^0.2.0", + "unrs-resolver": "^1.9.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-import-x" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^8.56.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "eslint-import-resolver-node": "*" + }, + "peerDependenciesMeta": { + "@typescript-eslint/utils": { + "optional": true + }, + "eslint-import-resolver-node": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-playwright": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.10.1.tgz", + "integrity": "sha512-qea3UxBOb8fTwJ77FMApZKvRye5DOluDHcev0LDJwID3RELeun0JlqzrNIXAB/SXCyB/AesCW/6sZfcT9q3Edg==", + "dev": true, + "license": "MIT", + "dependencies": { + "globals": "^17.3.0" + }, + "engines": { + "node": ">=16.9.0" + }, + "peerDependencies": { + "eslint": ">=8.40.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", + "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.12" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-plugin-react/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-storybook": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.3.5.tgz", + "integrity": "sha512-rEFkfU3ypF44GpB4tiJ9EFDItueoGvGi3+weLHZax2ON2MB7VIDsxdSUGvIU5tMURg+oWYlpzCyLm4TpDq2deA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.48.0" + }, + "peerDependencies": { + "eslint": ">=8", + "storybook": "^10.3.5" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eta": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/eta/-/eta-3.5.0.tgz", + "integrity": "sha512-e3x3FBvGzeCIHhF+zhK8FZA2vC5uFn6b4HJjegUbIWrDb4mJ7JjTGMJY9VGIbRVpmSwHopNiaJibhjIr+HfLug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + }, + "funding": { + "url": "https://github.com/eta-dev/eta?sponsor=1" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/framer-motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/giget": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.2.0.tgz", + "integrity": "sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==", + "dev": true, + "license": "MIT", + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", + "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http2-client": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/http2-client/-/http2-client-1.3.5.tgz", + "integrity": "sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/image-size": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz", + "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==", + "dev": true, + "license": "MIT", + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz", + "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.5", + "@asamuzakjp/dom-selector": "^7.0.6", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", + "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz", + "integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.38.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch-h2": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/node-fetch-h2/-/node-fetch-h2-2.3.0.tgz", + "integrity": "sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "http2-client": "^1.2.5" + }, + "engines": { + "node": "4.x || >=6.0.0" + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/node-readfiles": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/node-readfiles/-/node-readfiles-0.2.0.tgz", + "integrity": "sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es6-promise": "^3.2.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/oas-kit-common": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/oas-kit-common/-/oas-kit-common-1.0.8.tgz", + "integrity": "sha512-pJTS2+T0oGIwgjGpw7sIRU8RQMcUoKCDWFLdBqKB2BNmGpbBMH2sdqAaOXUg8OzonZHU0L7vfJu1mJFEiYDWOQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "fast-safe-stringify": "^2.0.7" + } + }, + "node_modules/oas-linter": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/oas-linter/-/oas-linter-3.2.2.tgz", + "integrity": "sha512-KEGjPDVoU5K6swgo9hJVA/qYGlwfbFx+Kg2QB/kd7rzV5N8N5Mg6PlsoCMohVnQmo+pzJap/F610qTodKzecGQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@exodus/schemasafe": "^1.0.0-rc.2", + "should": "^13.2.1", + "yaml": "^1.10.0" + }, + "funding": { + "url": "https://github.com/Mermade/oas-kit?sponsor=1" + } + }, + "node_modules/oas-linter/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/oas-resolver": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/oas-resolver/-/oas-resolver-2.5.6.tgz", + "integrity": "sha512-Yx5PWQNZomfEhPPOphFbZKi9W93CocQj18NlD2Pa4GWZzdZpSJvYwoiuurRI7m3SpcChrnO08hkuQDL3FGsVFQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "node-fetch-h2": "^2.3.0", + "oas-kit-common": "^1.0.8", + "reftools": "^1.1.9", + "yaml": "^1.10.0", + "yargs": "^17.0.1" + }, + "bin": { + "resolve": "resolve.js" + }, + "funding": { + "url": "https://github.com/Mermade/oas-kit?sponsor=1" + } + }, + "node_modules/oas-resolver/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/oas-schema-walker": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/oas-schema-walker/-/oas-schema-walker-1.1.5.tgz", + "integrity": "sha512-2yucenq1a9YPmeNExoUa9Qwrt9RFkjqaMAA1X+U7sbb0AqBeTIdMHky9SQQ6iN94bO5NW0W4TRYXerG+BdAvAQ==", + "dev": true, + "license": "BSD-3-Clause", + "funding": { + "url": "https://github.com/Mermade/oas-kit?sponsor=1" + } + }, + "node_modules/oas-validator": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/oas-validator/-/oas-validator-5.0.8.tgz", + "integrity": "sha512-cu20/HE5N5HKqVygs3dt94eYJfBi0TsZvPVXDhbXQHiEityDN+RROTleefoKRKKJ9dFAF2JBkDHgvWj0sjKGmw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "call-me-maybe": "^1.0.1", + "oas-kit-common": "^1.0.8", + "oas-linter": "^3.2.2", + "oas-resolver": "^2.5.6", + "oas-schema-walker": "^1.1.5", + "reftools": "^1.1.9", + "should": "^13.2.1", + "yaml": "^1.10.0" + }, + "funding": { + "url": "https://github.com/Mermade/oas-kit?sponsor=1" + } + }, + "node_modules/oas-validator/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path": { + "version": "0.12.7", + "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", + "integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==", + "license": "MIT", + "dependencies": { + "process": "^0.11.1", + "util": "^0.10.3" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", + "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz", + "integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rc9": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", + "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.6", + "destr": "^2.0.5" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-docgen": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-8.0.3.tgz", + "integrity": "sha512-aEZ9qP+/M+58x2qgfSFEWH1BxLyHe5+qkLNJOZQb5iGS017jpbRnoKhNRrXPeA6RfBrZO5wZrT9DMC1UqE1f1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.2", + "@types/babel__core": "^7.20.5", + "@types/babel__traverse": "^7.20.7", + "@types/doctrine": "^0.0.9", + "@types/resolve": "^1.20.2", + "doctrine": "^3.0.0", + "resolve": "^1.22.1", + "strip-indent": "^4.0.0" + }, + "engines": { + "node": "^20.9.0 || >=22" + } + }, + "node_modules/react-docgen-typescript": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/react-docgen-typescript/-/react-docgen-typescript-2.4.0.tgz", + "integrity": "sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">= 4.3.x" + } + }, + "node_modules/react-docgen/node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-is": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", + "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.1.tgz", + "integrity": "sha512-5BCvFskyAAVumqhEKh/iPhLOIkfxcEUz8WqFIARCkMg8hZZzDYX9CtwxXA0e+qT8zAxmMC0x3Ckb9iMONwc5jg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.1.tgz", + "integrity": "sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==", + "license": "MIT", + "dependencies": { + "react-router": "7.14.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/recast": { + "version": "0.23.11", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", + "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts/node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/redent/node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reftools": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/reftools/-/reftools-1.1.9.tgz", + "integrity": "sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w==", + "dev": true, + "license": "BSD-3-Clause", + "funding": { + "url": "https://github.com/Mermade/oas-kit?sponsor=1" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/should": { + "version": "13.2.3", + "resolved": "https://registry.npmjs.org/should/-/should-13.2.3.tgz", + "integrity": "sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "should-equal": "^2.0.0", + "should-format": "^3.0.3", + "should-type": "^1.4.0", + "should-type-adaptors": "^1.0.1", + "should-util": "^1.0.0" + } + }, + "node_modules/should-equal": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/should-equal/-/should-equal-2.0.0.tgz", + "integrity": "sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "should-type": "^1.4.0" + } + }, + "node_modules/should-format": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/should-format/-/should-format-3.0.3.tgz", + "integrity": "sha512-hZ58adtulAk0gKtua7QxevgUaXTTXxIi8t41L3zo9AHvjXO1/7sdLECuHeIN2SRtYXpNkmhoUP2pdeWgricQ+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "should-type": "^1.3.0", + "should-type-adaptors": "^1.0.1" + } + }, + "node_modules/should-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/should-type/-/should-type-1.4.0.tgz", + "integrity": "sha512-MdAsTu3n25yDbIe1NeN69G4n6mUnJGtSJHygX3+oN0ZbO3DTiATnf7XnYJdGT42JCXurTb1JI0qOBR65shvhPQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/should-type-adaptors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/should-type-adaptors/-/should-type-adaptors-1.1.0.tgz", + "integrity": "sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "should-type": "^1.3.0", + "should-util": "^1.0.0" + } + }, + "node_modules/should-util": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/should-util/-/should-util-1.0.1.tgz", + "integrity": "sha512-oXF8tfxx5cDk8r2kYqlkUJzZpDBqVY/II2WhvU0n9Y3XYvAYRmeaf1PvvIvTgPnv4KJ+ES5M0PyDq5Jp+Ygy2g==", + "dev": true, + "license": "MIT" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable-hash-x": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", + "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/storybook": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.3.5.tgz", + "integrity": "sha512-uBSZu/GZa9aEIW3QMGvdQPMZWhGxSe4dyRWU8B3/Vd47Gy/XLC7tsBxRr13txmmPOEDHZR94uLuq0H50fvuqBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@storybook/icons": "^2.0.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/user-event": "^14.6.1", + "@vitest/expect": "3.2.4", + "@vitest/spy": "3.2.4", + "@webcontainer/env": "^1.1.1", + "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", + "open": "^10.2.0", + "recast": "^0.23.5", + "semver": "^7.7.3", + "use-sync-external-store": "^1.5.0", + "ws": "^8.18.0" + }, + "bin": { + "storybook": "dist/bin/dispatcher.js" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "prettier": "^2 || ^3" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + } + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-indent": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz", + "integrity": "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/swagger-schema-official": { + "version": "2.0.0-bab6bed", + "resolved": "https://registry.npmjs.org/swagger-schema-official/-/swagger-schema-official-2.0.0-bab6bed.tgz", + "integrity": "sha512-rCC0NWGKr/IJhtRuPq/t37qvZHI/mH4I4sxflVM+qgVe5Z2uOCivzWaVbuioJaB61kvm5UvB7b49E+oBY0M8jA==", + "dev": true, + "license": "ISC" + }, + "node_modules/swagger-typescript-api": { + "version": "13.6.10", + "resolved": "https://registry.npmjs.org/swagger-typescript-api/-/swagger-typescript-api-13.6.10.tgz", + "integrity": "sha512-X+DenKK2txtI0LdNcpmWddcZWiWaf6erIZ+m9Wz7vk/yMFmPJ9EljSuX+dnJQ9YuzjV1imaRCDKsHA4CG2wUZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/swagger-parser": "12.1.0", + "@biomejs/js-api": "4.0.0", + "@biomejs/wasm-nodejs": "2.4.12", + "@types/swagger-schema-official": "^2.0.25", + "c12": "^3.3.3", + "citty": "^0.2.1", + "consola": "^3.4.2", + "es-toolkit": "^1.44.0", + "eta": "^3.5.0", + "nanoid": "^5.1.6", + "openapi-types": "^12.1.3", + "swagger-schema-official": "2.0.0-bab6bed", + "swagger2openapi": "^7.0.8", + "type-fest": "^5.4.4", + "typescript": "~6.0.2", + "yaml": "^2.8.2", + "yummies": "7.18.0" + }, + "bin": { + "sta": "dist/cli.mjs", + "swagger-typescript-api": "dist/cli.mjs" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/swagger-typescript-api/node_modules/nanoid": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.7.tgz", + "integrity": "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/swagger2openapi": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/swagger2openapi/-/swagger2openapi-7.0.8.tgz", + "integrity": "sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "call-me-maybe": "^1.0.1", + "node-fetch": "^2.6.1", + "node-fetch-h2": "^2.3.0", + "node-readfiles": "^0.2.0", + "oas-kit-common": "^1.0.8", + "oas-resolver": "^2.5.6", + "oas-schema-walker": "^1.1.5", + "oas-validator": "^5.0.8", + "reftools": "^1.1.9", + "yaml": "^1.10.0", + "yargs": "^17.0.1" + }, + "bin": { + "boast": "boast.js", + "oas-validate": "oas-validate.js", + "swagger2openapi": "swagger2openapi.js" + }, + "funding": { + "url": "https://github.com/Mermade/oas-kit?sponsor=1" + } + }, + "node_modules/swagger2openapi/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/synckit": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.28" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", + "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", + "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "license": "MIT", + "dependencies": { + "inherits": "2.0.3" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.15", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-checker": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/vite-plugin-checker/-/vite-plugin-checker-0.13.0.tgz", + "integrity": "sha512-14EkOZmfinVZNxRmg2uCNDwtqGc/33lU/UEJansHgu27+ad+r6mMBf1Xtnq57jGZWiO/xzwtiEKPYsganw7ZFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "chokidar": "^4.0.3", + "npm-run-path": "^6.0.0", + "picocolors": "^1.1.1", + "picomatch": "^4.0.4", + "proper-lockfile": "^4.1.2", + "tiny-invariant": "^1.3.3", + "tinyglobby": "^0.2.15", + "vscode-uri": "^3.1.0" + }, + "engines": { + "node": ">=16.11" + }, + "peerDependencies": { + "@biomejs/biome": ">=1.7", + "eslint": ">=9.39.4", + "meow": "^13.2.0 || ^14.0.0", + "optionator": "^0.9.4", + "oxlint": ">=1", + "stylelint": ">=16.26.1", + "typescript": "*", + "vite": ">=5.4.21", + "vls": "*", + "vti": "*", + "vue-tsc": "~2.2.10 || ^3.0.0" + }, + "peerDependenciesMeta": { + "@biomejs/biome": { + "optional": true + }, + "eslint": { + "optional": true + }, + "meow": { + "optional": true + }, + "optionator": { + "optional": true + }, + "oxlint": { + "optional": true + }, + "stylelint": { + "optional": true + }, + "typescript": { + "optional": true + }, + "vls": { + "optional": true + }, + "vti": { + "optional": true + }, + "vue-tsc": { + "optional": true + } + } + }, + "node_modules/vite-plugin-checker/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/vite-plugin-checker/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/expect": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/spy": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yummies": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/yummies/-/yummies-7.18.0.tgz", + "integrity": "sha512-fgldINrxPi20XJjIa1LOniyqHROm5HwDmmq0VZtQml4u3cMerm8H7H2BM8kMhHilgd8l7rg3mN4xt2apc2l/xA==", + "dev": true, + "license": "MIT", + "dependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "dayjs": "^1.11.20", + "dompurify": "^3.3.3", + "nanoid": "^5.1.7", + "tailwind-merge": "^3.5.0" + }, + "peerDependencies": { + "mobx": "^6.12.4", + "react": "^18 || ^19" + }, + "peerDependenciesMeta": { + "mobx": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/yummies/node_modules/nanoid": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.9.tgz", + "integrity": "sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/client/package.json b/client/package.json new file mode 100644 index 000000000..00eaec110 --- /dev/null +++ b/client/package.json @@ -0,0 +1,95 @@ +{ + "name": "fleet-client", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "npm run dev:protoOS", + "dev:protoOS": "npx tsx ./scripts/dev-protoOS.ts", + "dev:protoFleet": "vite --mode protoFleet", + "prebuild": "npm run lint", + "build": "tsc && vite build --mode protoOS && vite build --mode protoFleet", + "build:protoOS": "tsc && vite build --mode protoOS", + "build:protoFleet": "tsc && vite build --mode protoFleet", + "generate-api-types": "node ./scripts/generate_api_ts.mjs", + "lint": "eslint . --report-unused-disable-directives --max-warnings 0", + "preview:protoOS": "vite preview --mode protoOS", + "preview:protoFleet": "vite preview --mode protoFleet", + "storybook": "storybook dev -p 6006", + "build-storybook": "tsc --noEmit && BUILD_STORYBOOK=1 storybook build", + "test": "vitest", + "test:e2e": "cd e2eTests/protoFleet && npx playwright install && npx playwright test --project=desktop", + "test:e2e:ui": "cd e2eTests/protoFleet && npx playwright install && npx playwright test --ui --project=desktop", + "test:e2e:headed": "cd e2eTests/protoFleet && npx playwright install && npx playwright test --headed --project=desktop", + "upload": "scp -Crp dist/* admin@$npm_config_host:/home/admin/", + "format": "prettier --list-different --write \"./**/*.{js,jsx,ts,tsx,css,md}\"", + "format:check": "prettier --check \"./**/*.{js,jsx,ts,tsx,css,md}\"", + "bootstrap": "npx tsx ./scripts/auth_discover_pair.ts" + }, + "dependencies": { + "@bufbuild/protobuf": "2.11.0", + "@connectrpc/connect": "2.1.1", + "@connectrpc/connect-web": "2.1.1", + "@dnd-kit/core": "6.3.1", + "@dnd-kit/sortable": "10.0.0", + "clsx": "2.1.1", + "immer": "11.1.4", + "motion": "12.38.0", + "path": "0.12.7", + "react": "19.2.5", + "react-dom": "19.2.5", + "react-router-dom": "7.14.1", + "recharts": "3.8.1", + "zustand": "5.0.12" + }, + "devDependencies": { + "@bufbuild/buf": "1.68.1", + "@bufbuild/protoc-gen-es": "2.11.0", + "@eslint/compat": "2.0.5", + "@eslint/js": "10.0.1", + "@playwright/test": "1.59.1", + "@storybook/addon-docs": "10.3.5", + "@storybook/react": "10.3.5", + "@storybook/react-vite": "10.3.5", + "@tailwindcss/postcss": "4.2.2", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/react": "16.3.2", + "@testing-library/user-event": "14.6.1", + "@types/node": "25.6.0", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "@typescript-eslint/eslint-plugin": "8.58.2", + "@typescript-eslint/parser": "8.58.2", + "@vitejs/plugin-react": "6.0.1", + "chromatic": "16.3.0", + "concurrently": "9.2.1", + "eslint": "10.2.0", + "eslint-config-prettier": "10.1.8", + "eslint-plugin-import-x": "4.16.2", + "eslint-plugin-jsx-a11y": "6.10.2", + "eslint-plugin-playwright": "2.10.1", + "eslint-plugin-prettier": "5.5.5", + "eslint-plugin-react": "7.37.5", + "eslint-plugin-react-hooks": "7.0.1", + "eslint-plugin-react-refresh": "0.5.2", + "eslint-plugin-storybook": "10.3.5", + "globals": "17.5.0", + "image-size": "2.0.2", + "jsdom": "29.0.2", + "postcss": "8.5.10", + "prettier": "3.8.3", + "prettier-plugin-tailwindcss": "0.7.2", + "storybook": "10.3.5", + "swagger-typescript-api": "13.6.10", + "tailwindcss": "4.2.2", + "tsx": "4.21.0", + "typescript": "6.0.2", + "vite": "8.0.8", + "vite-plugin-checker": "0.13.0", + "vitest": "4.1.4" + }, + "overrides": { + "eslint": "10.2.0", + "typescript": "6.0.2" + } +} diff --git a/client/postcss.config.js b/client/postcss.config.js new file mode 100644 index 000000000..c2ddf7482 --- /dev/null +++ b/client/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; diff --git a/client/public/favicon.png b/client/public/favicon.png new file mode 100644 index 000000000..87b8cec63 Binary files /dev/null and b/client/public/favicon.png differ diff --git a/client/public/fonts/Inter/InterVariable-Italic.woff2 b/client/public/fonts/Inter/InterVariable-Italic.woff2 new file mode 100644 index 000000000..f22ec2554 Binary files /dev/null and b/client/public/fonts/Inter/InterVariable-Italic.woff2 differ diff --git a/client/public/fonts/Inter/InterVariable.woff2 b/client/public/fonts/Inter/InterVariable.woff2 new file mode 100644 index 000000000..22a12b04e Binary files /dev/null and b/client/public/fonts/Inter/InterVariable.woff2 differ diff --git a/client/public/fonts/JetBrainsMono/JetBrainsMono-Italic[wght].ttf b/client/public/fonts/JetBrainsMono/JetBrainsMono-Italic[wght].ttf new file mode 100644 index 000000000..541483553 Binary files /dev/null and b/client/public/fonts/JetBrainsMono/JetBrainsMono-Italic[wght].ttf differ diff --git a/client/public/fonts/JetBrainsMono/JetBrainsMono[wght].ttf b/client/public/fonts/JetBrainsMono/JetBrainsMono[wght].ttf new file mode 100644 index 000000000..b60e77f5d Binary files /dev/null and b/client/public/fonts/JetBrainsMono/JetBrainsMono[wght].ttf differ diff --git a/client/scripts/auth_discover_pair.ts b/client/scripts/auth_discover_pair.ts new file mode 100644 index 000000000..aed7c2f21 --- /dev/null +++ b/client/scripts/auth_discover_pair.ts @@ -0,0 +1,341 @@ +/** + * Fleet Onboarding, Discovery, and Pairing Script + * + * End-to-end script that bootstraps a Fleet instance by: + * 1. Authenticating (creating an admin user via REST if needed) + * 2. Resolving a discovery target subnet (from env or the NetworkInfo REST endpoint) + * 3. Running nmap-based device discovery via Connect-RPC streaming + * 4. Pairing newly discovered devices (all or Proto-only, based on env config) + * 5. Reporting the final fleet inventory + * + * Auth and onboarding use the REST API; discovery and pairing use Connect-RPC. + * + * Environment variables: + * FLEET_API_URL – server base URL (default: http://localhost:4000) + * FLEET_ADMIN_USERNAME – admin username (default: admin) + * FLEET_ADMIN_PASSWORD – admin password (default: Pass123!) + * FLEET_SESSION_COOKIE – skip auth and use an existing session cookie + * FLEET_DISCOVERY_TARGET – subnet/IP to scan (default: auto-detected via NetworkInfo) + * FLEET_DISCOVERY_PORTS – comma-separated ports to scan (default: server-advertised ports) + * FLEET_PAIR_ALL_DISCOVERED – "true" to pair all devices, not just Proto rigs + * + * Run with: + * npx tsx client/scripts/auth_discover_pair.ts + */ + +import { create } from "@bufbuild/protobuf"; +import { createClient } from "@connectrpc/connect"; +import { createConnectTransport } from "@connectrpc/connect-web"; +import { DeviceIdentifierListSchema } from "../src/protoFleet/api/generated/common/v1/device_selector_pb"; +import { + FleetManagementService, + ListMinerStateSnapshotsRequestSchema, + PairingStatus, +} from "../src/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { DeviceSelectorSchema } from "../src/protoFleet/api/generated/minercommand/v1/command_pb"; +import { + PairRequestSchema, + PairingService, + DiscoverRequestSchema, + type Device, +} from "../src/protoFleet/api/generated/pairing/v1/pairing_pb"; + +const baseUrl = process.env.FLEET_API_URL ?? "http://localhost:4000"; +const adminUsername = process.env.FLEET_ADMIN_USERNAME ?? "admin"; +const adminPassword = process.env.FLEET_ADMIN_PASSWORD ?? "Pass123!"; +const requestedSessionCookie = process.env.FLEET_SESSION_COOKIE; +const requestedDiscoveryTarget = process.env.FLEET_DISCOVERY_TARGET; +const requestedDiscoveryPorts = process.env.FLEET_DISCOVERY_PORTS; +const discoveryPorts = requestedDiscoveryPorts + ? requestedDiscoveryPorts + .split(",") + .map((port) => port.trim()) + .filter(Boolean) + : []; +const pairAllDiscovered = process.env.FLEET_PAIR_ALL_DISCOVERED === "true"; + +const transport = createConnectTransport({ baseUrl }); +const pairingClient = createClient(PairingService, transport); +const fleetClient = createClient(FleetManagementService, transport); + +type FleetInitStatusResponse = { + status?: { + adminCreated?: boolean; + }; +}; + +type NetworkInfoResponse = { + networkInfo?: { + subnet?: string; + localIp?: string; + gateway?: string; + }; +}; + +type AuthenticateResponse = { + userInfo?: { + username?: string; + }; + sessionExpiry?: string | number; +}; + +async function postJson( + path: string, + body: unknown, + sessionCookie?: string, +): Promise<{ data: T; setCookie: string | null }> { + const headers = new Headers({ + "Content-Type": "application/json", + }); + + if (sessionCookie) { + headers.set("Cookie", sessionCookie); + } + + const response = await fetch(`${baseUrl}${path}`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + const responseText = await response.text(); + if (!response.ok) { + throw new Error(`${path} failed with ${response.status}: ${responseText}`); + } + + return { + data: responseText ? (JSON.parse(responseText) as T) : ({} as T), + setCookie: response.headers.get("set-cookie"), + }; +} + +function formatSessionCookie(rawCookie: string): string { + if (rawCookie.includes("=")) { + return rawCookie; + } + + return `fleet_session=${rawCookie}`; +} + +function normalizeSubnetCIDR(value: string): string { + const [ip, prefixString] = value.split("/"); + if (!ip || !prefixString) { + return value; + } + + const octets = ip.split(".").map((part) => Number(part)); + const prefix = Number(prefixString); + const validIPv4 = + octets.length === 4 && octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255); + if (!validIPv4 || !Number.isInteger(prefix) || prefix < 0 || prefix > 32) { + return value; + } + + const ipInt = octets.reduce((acc, octet) => (acc << 8) | octet, 0) >>> 0; + const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0; + const network = ipInt & mask; + + const normalizedOctets = [(network >>> 24) & 0xff, (network >>> 16) & 0xff, (network >>> 8) & 0xff, network & 0xff]; + + return `${normalizedOctets.join(".")}/${prefix}`; +} + +// Step 1: Authenticate — reuse an existing cookie, or onboard + login via REST +async function ensureSessionCookie(): Promise { + if (requestedSessionCookie) { + const cookie = formatSessionCookie(requestedSessionCookie); + console.log(`Using existing Fleet session cookie for ${baseUrl}`); + return cookie; + } + + const initStatus = await postJson("/onboarding.v1.OnboardingService/GetFleetInitStatus", {}); + + if (!initStatus.data.status?.adminCreated) { + console.log(`Creating Fleet admin user ${adminUsername}...`); + await postJson("/onboarding.v1.OnboardingService/CreateAdminLogin", { + username: adminUsername, + password: adminPassword, + }); + } else { + console.log(`Fleet already onboarded. Authenticating as ${adminUsername}...`); + } + + const authResponse = await postJson("/auth.v1.AuthService/Authenticate", { + username: adminUsername, + password: adminPassword, + }); + + if (!authResponse.setCookie) { + throw new Error("Authenticate succeeded but no session cookie was returned."); + } + + const sessionCookie = authResponse.setCookie.split(";")[0]; + const username = authResponse.data.userInfo?.username ?? adminUsername; + console.log(`Authenticated as ${username}. Session cookie captured.`); + return sessionCookie; +} + +// Step 2: Resolve subnet — use the env override or query the NetworkInfo REST endpoint +async function getDiscoveryTarget(sessionCookie: string): Promise { + if (requestedDiscoveryTarget) { + return requestedDiscoveryTarget; + } + + const response = await postJson( + "/networkinfo.v1.NetworkInfoService/GetNetworkInfo", + {}, + sessionCookie, + ); + + const subnet = response.data.networkInfo?.subnet; + if (!subnet) { + throw new Error("Network info response did not include a subnet."); + } + + const normalizedSubnet = normalizeSubnetCIDR(subnet); + console.log( + `Using discovery target ${normalizedSubnet} (backend reported subnet ${subnet}, local IP ${response.data.networkInfo?.localIp ?? "unknown"}).`, + ); + return normalizedSubnet; +} + +// Step 3: Discover devices via nmap Connect-RPC streaming +async function discoverDevices(sessionCookie: string, target: string): Promise { + const request = create(DiscoverRequestSchema, { + mode: { + case: "nmap", + value: + discoveryPorts.length > 0 + ? { + target, + ports: discoveryPorts, + } + : { + target, + }, + }, + }); + + const discovered = new Map(); + + for await (const response of pairingClient.discover(request, { + headers: { + Cookie: sessionCookie, + }, + })) { + if (response.error) { + console.warn(`Discovery warning: ${response.error}`); + } + + for (const device of response.devices) { + discovered.set(device.deviceIdentifier, device); + } + } + + return [...discovered.values()]; +} + +// Step 4: Pair selected devices via Connect-RPC +async function pairDevices(sessionCookie: string, devices: Device[]): Promise { + if (devices.length === 0) { + return []; + } + + const request = create(PairRequestSchema, { + deviceSelector: create(DeviceSelectorSchema, { + selectionType: { + case: "includeDevices", + value: create(DeviceIdentifierListSchema, { + deviceIdentifiers: devices.map((device) => device.deviceIdentifier), + }), + }, + }), + }); + + const response = await pairingClient.pair(request, { + headers: { + Cookie: sessionCookie, + }, + }); + + return response.failedDeviceIds; +} + +// Step 5: List current fleet inventory via Connect-RPC +async function listMiners(sessionCookie: string) { + const response = await fleetClient.listMinerStateSnapshots( + create(ListMinerStateSnapshotsRequestSchema, { + pageSize: 100, + }), + { + headers: { + Cookie: sessionCookie, + }, + }, + ); + + return response.miners; +} + +function summarizeDevices(label: string, devices: Device[]) { + const counts = new Map(); + + for (const device of devices) { + const key = `${device.driverName}:${device.manufacturer} ${device.model}`; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + + console.log(`${label}: ${devices.length}`); + for (const [key, count] of [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { + console.log(` ${count} x ${key}`); + } +} + +async function main() { + console.log(`Bootstrapping Fleet fake-miner setup against ${baseUrl}`); + + const sessionCookie = await ensureSessionCookie(); + const discoveryTarget = await getDiscoveryTarget(sessionCookie); + + console.log(`Scanning ${discoveryTarget} on ports ${discoveryPorts.join(", ")}...`); + const discoveredDevices = await discoverDevices(sessionCookie, discoveryTarget); + summarizeDevices("Discovered devices", discoveredDevices); + + const currentMiners = await listMiners(sessionCookie); + const alreadyPairedIds = new Set( + currentMiners + .filter((miner) => miner.pairingStatus === PairingStatus.PAIRED) + .map((miner) => miner.deviceIdentifier), + ); + + const candidateDevices = pairAllDiscovered + ? discoveredDevices + : discoveredDevices.filter((device) => device.driverName === "proto"); + const devicesToPair = candidateDevices.filter((device) => !alreadyPairedIds.has(device.deviceIdentifier)); + + summarizeDevices(pairAllDiscovered ? "Pairing all discovered devices" : "Pairing Proto devices", devicesToPair); + + if (devicesToPair.length === 0) { + console.log("No newly discovered devices need pairing."); + console.log( + `Fleet now has ${currentMiners.length} miner snapshot(s), including ${currentMiners.filter((miner) => miner.driverName === "proto").length} Proto rig(s).`, + ); + return; + } + + const failedDeviceIds = await pairDevices(sessionCookie, devicesToPair); + if (failedDeviceIds.length > 0) { + console.warn(`Pairing failed for ${failedDeviceIds.length} device(s): ${failedDeviceIds.join(", ")}`); + } else { + console.log("Pairing completed without failures."); + } + + const miners = await listMiners(sessionCookie); + const pairedProtoMiners = miners.filter((miner) => miner.driverName === "proto"); + console.log(`Fleet now has ${miners.length} miner snapshot(s), including ${pairedProtoMiners.length} Proto rig(s).`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/client/scripts/dev-protoOS.ts b/client/scripts/dev-protoOS.ts new file mode 100644 index 000000000..59d605e52 --- /dev/null +++ b/client/scripts/dev-protoOS.ts @@ -0,0 +1,24 @@ +#!/usr/bin/env node + +import { spawn } from "child_process"; +import { config } from "dotenv"; +import { fileURLToPath } from "url"; +import { dirname, resolve } from "path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Load .env file +config({ path: resolve(__dirname, "../.env") }); + +console.log("Starting ProtoOS..."); + +// Start vite normally +const vite = spawn("vite", ["--mode", "protoOS"], { + stdio: "inherit", + shell: true, +}); + +vite.on("exit", (code) => { + process.exit(code || 0); +}); diff --git a/client/scripts/generate_api_ts.mjs b/client/scripts/generate_api_ts.mjs new file mode 100644 index 000000000..8d3f9d57a --- /dev/null +++ b/client/scripts/generate_api_ts.mjs @@ -0,0 +1,42 @@ +#!/usr/bin/env node + +import fs from "fs"; +import path from "path"; +import { generateApi } from "swagger-typescript-api"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); // get the resolved path to the file +const __dirname = path.dirname(__filename); // get the name of the directory + +const swaggerSchemaPath = path.resolve( + __dirname, + "../../proto-rig-api/openapi/MDK-API.json", +); + +if (!fs.existsSync(swaggerSchemaPath)) { + console.error(`\nCould not find Swagger Schema file: ${swaggerSchemaPath}\n`); + process.exitCode = 1; +} + +const [fileName = "generatedApi.ts"] = process.argv.slice(2); +const fileDir = path.resolve(__dirname, "../src/protoOS/api"); + +generateApi({ + fileName, + input: swaggerSchemaPath, + extractRequestParams: true, + output: fileDir, + addReadonly: true, + httpClientType: "fetch", + sortTypes: true, +}).then(() => { + const filePath = path.join(fileDir, fileName); + let fileContent = fs.readFileSync(filePath, "utf-8"); + + fileContent = fileContent.replace( + /public baseUrl: string = ".*";/g, + 'public baseUrl: string = "";', + ); + + fs.writeFileSync(filePath, fileContent, "utf-8"); +}); diff --git a/client/src/protoFleet/api/ScheduleApiContext.ts b/client/src/protoFleet/api/ScheduleApiContext.ts new file mode 100644 index 000000000..b820f5806 --- /dev/null +++ b/client/src/protoFleet/api/ScheduleApiContext.ts @@ -0,0 +1,15 @@ +import { createContext, useContext } from "react"; + +import type { UseScheduleApiResult } from "@/protoFleet/api/useScheduleApi"; + +export const ScheduleApiContext = createContext(null); + +export const useScheduleApiContext = () => { + const scheduleApi = useContext(ScheduleApiContext); + + if (scheduleApi === null) { + throw new Error("useScheduleApiContext must be used within a ScheduleApiProvider"); + } + + return scheduleApi; +}; diff --git a/client/src/protoFleet/api/ScheduleApiProvider.tsx b/client/src/protoFleet/api/ScheduleApiProvider.tsx new file mode 100644 index 000000000..c768c1c95 --- /dev/null +++ b/client/src/protoFleet/api/ScheduleApiProvider.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; + +import { ScheduleApiContext } from "@/protoFleet/api/ScheduleApiContext"; +import useScheduleApi from "@/protoFleet/api/useScheduleApi"; + +export const ScheduleApiProvider = ({ children }: { children: ReactNode }) => { + const scheduleApi = useScheduleApi(); + + return {children}; +}; diff --git a/client/src/protoFleet/api/clients.ts b/client/src/protoFleet/api/clients.ts new file mode 100644 index 000000000..1fd3dce71 --- /dev/null +++ b/client/src/protoFleet/api/clients.ts @@ -0,0 +1,48 @@ +import { createClient } from "@connectrpc/connect"; +import { transport } from "./transport"; +import { ActivityService } from "@/protoFleet/api/generated/activity/v1/activity_pb"; +import { ApiKeyService } from "@/protoFleet/api/generated/apikey/v1/apikey_pb"; +import { AuthService } from "@/protoFleet/api/generated/auth/v1/auth_pb"; +import { DeviceSetService } from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; +import { ErrorQueryService } from "@/protoFleet/api/generated/errors/v1/errors_pb"; +import { FleetManagementService } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { ForemanImportService } from "@/protoFleet/api/generated/foremanimport/v1/foremanimport_pb"; +import { MinerCommandService } from "@/protoFleet/api/generated/minercommand/v1/command_pb"; +import { NetworkInfoService } from "@/protoFleet/api/generated/networkinfo/v1/networkinfo_pb"; +import { OnboardingService } from "@/protoFleet/api/generated/onboarding/v1/onboarding_pb"; +import { PairingService } from "@/protoFleet/api/generated/pairing/v1/pairing_pb"; +import { PoolsService } from "@/protoFleet/api/generated/pools/v1/pools_pb"; +import { ScheduleService } from "@/protoFleet/api/generated/schedule/v1/schedule_pb"; +import { TelemetryService } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; + +const activityClient = createClient(ActivityService, transport); +const apiKeyClient = createClient(ApiKeyService, transport); +const authClient = createClient(AuthService, transport); +const errorQueryClient = createClient(ErrorQueryService, transport); +const networkInfoClient = createClient(NetworkInfoService, transport); +const pairingClient = createClient(PairingService, transport); +const fleetManagementClient = createClient(FleetManagementService, transport); +const onboardingClient = createClient(OnboardingService, transport); +const minerCommandClient = createClient(MinerCommandService, transport); +const poolsClient = createClient(PoolsService, transport); +const scheduleClient = createClient(ScheduleService, transport); +const deviceSetClient = createClient(DeviceSetService, transport); +const telemetryClient = createClient(TelemetryService, transport); +const foremanImportClient = createClient(ForemanImportService, transport); + +export { + activityClient, + apiKeyClient, + authClient, + deviceSetClient, + errorQueryClient, + networkInfoClient, + pairingClient, + fleetManagementClient, + onboardingClient, + minerCommandClient, + poolsClient, + scheduleClient, + telemetryClient, + foremanImportClient, +}; diff --git a/client/src/protoFleet/api/constants.ts b/client/src/protoFleet/api/constants.ts new file mode 100644 index 000000000..cb6563c79 --- /dev/null +++ b/client/src/protoFleet/api/constants.ts @@ -0,0 +1 @@ +export const API_PROXY_BASE = "/api-proxy"; diff --git a/client/src/protoFleet/api/fetchAllMinerSnapshots.test.ts b/client/src/protoFleet/api/fetchAllMinerSnapshots.test.ts new file mode 100644 index 000000000..d776987c5 --- /dev/null +++ b/client/src/protoFleet/api/fetchAllMinerSnapshots.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchAllMinerSnapshots } from "./fetchAllMinerSnapshots"; + +const mockListMinerStateSnapshots = vi.fn(); + +vi.mock("@/protoFleet/api/clients", () => ({ + fleetManagementClient: { + listMinerStateSnapshots: (...args: unknown[]) => mockListMinerStateSnapshots(...args), + }, +})); + +function minerSnapshot(deviceIdentifier: string) { + return { deviceIdentifier } as { deviceIdentifier: string }; +} + +describe("fetchAllMinerSnapshots", () => { + beforeEach(() => { + mockListMinerStateSnapshots.mockReset(); + }); + + it("returns a map from a single page", async () => { + mockListMinerStateSnapshots.mockResolvedValueOnce({ + miners: [minerSnapshot("d1"), minerSnapshot("d2")], + cursor: "", + }); + + const result = await fetchAllMinerSnapshots({ groupIds: [1n] }); + + expect(result).toEqual({ d1: minerSnapshot("d1"), d2: minerSnapshot("d2") }); + expect(mockListMinerStateSnapshots).toHaveBeenCalledTimes(1); + expect(mockListMinerStateSnapshots).toHaveBeenCalledWith( + { pageSize: 1000, cursor: "", filter: { groupIds: [1n] } }, + { signal: undefined }, + ); + }); + + it("accumulates results across multiple pages", async () => { + mockListMinerStateSnapshots + .mockResolvedValueOnce({ + miners: [minerSnapshot("d1"), minerSnapshot("d2")], + cursor: "page2", + }) + .mockResolvedValueOnce({ + miners: [minerSnapshot("d3")], + cursor: "", + }); + + const result = await fetchAllMinerSnapshots({ rackIds: [5n] }); + + expect(result).toEqual({ + d1: minerSnapshot("d1"), + d2: minerSnapshot("d2"), + d3: minerSnapshot("d3"), + }); + expect(mockListMinerStateSnapshots).toHaveBeenCalledTimes(2); + expect(mockListMinerStateSnapshots).toHaveBeenNthCalledWith( + 2, + { pageSize: 1000, cursor: "page2", filter: { rackIds: [5n] } }, + expect.anything(), + ); + }); + + it("throws AbortError when signal is already aborted", async () => { + const controller = new AbortController(); + controller.abort(); + + await expect(fetchAllMinerSnapshots({}, controller.signal)).rejects.toThrow( + expect.objectContaining({ name: "AbortError" }), + ); + expect(mockListMinerStateSnapshots).not.toHaveBeenCalled(); + }); + + it("throws when signal is aborted between pages", async () => { + const controller = new AbortController(); + + mockListMinerStateSnapshots.mockImplementationOnce(async () => { + controller.abort(); + return { miners: [minerSnapshot("d1")], cursor: "page2" }; + }); + + await expect(fetchAllMinerSnapshots({}, controller.signal)).rejects.toThrow( + expect.objectContaining({ name: "AbortError" }), + ); + expect(mockListMinerStateSnapshots).toHaveBeenCalledTimes(1); + }); + + it("propagates RPC errors without returning partial data", async () => { + const rpcError = new Error("server unavailable"); + + mockListMinerStateSnapshots + .mockResolvedValueOnce({ + miners: [minerSnapshot("d1")], + cursor: "page2", + }) + .mockRejectedValueOnce(rpcError); + + await expect(fetchAllMinerSnapshots({})).rejects.toThrow("server unavailable"); + expect(mockListMinerStateSnapshots).toHaveBeenCalledTimes(2); + }); +}); diff --git a/client/src/protoFleet/api/fetchAllMinerSnapshots.ts b/client/src/protoFleet/api/fetchAllMinerSnapshots.ts new file mode 100644 index 000000000..0574d9ce5 --- /dev/null +++ b/client/src/protoFleet/api/fetchAllMinerSnapshots.ts @@ -0,0 +1,42 @@ +import { fleetManagementClient } from "@/protoFleet/api/clients"; +import type { + MinerListFilter, + MinerStateSnapshot, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +type MinerListFilterInit = Omit, "$typeName" | "$unknown">; + +/** + * Paginate through all pages of `ListMinerStateSnapshots` and return a + * map of `deviceIdentifier → MinerStateSnapshot`. + * + * The server caps `page_size` at 1000, so device sets with more members + * require multiple round-trips. Results are accumulated locally and + * returned only after every page succeeds — callers never see partial data. + */ +export async function fetchAllMinerSnapshots( + filter: MinerListFilterInit, + signal?: AbortSignal, +): Promise> { + const map: Record = {}; + let cursor = ""; + + do { + if (signal?.aborted) { + throw new DOMException("The operation was aborted.", "AbortError"); + } + + const response = await fleetManagementClient.listMinerStateSnapshots( + { pageSize: 1000, cursor, filter }, + { signal }, + ); + + for (const miner of response.miners) { + map[miner.deviceIdentifier] = miner; + } + + cursor = response.cursor; + } while (cursor); + + return map; +} diff --git a/client/src/protoFleet/api/generated/activity/v1/activity_pb.ts b/client/src/protoFleet/api/generated/activity/v1/activity_pb.ts new file mode 100644 index 000000000..29da137e5 --- /dev/null +++ b/client/src/protoFleet/api/generated/activity/v1/activity_pb.ts @@ -0,0 +1,455 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file activity/v1/activity.proto (package activity.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_struct, file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import { file_buf_validate_validate } from "../../buf/validate/validate_pb"; +import type { JsonObject, Message } from "@bufbuild/protobuf"; + +/** + * Describes the file activity/v1/activity.proto. + */ +export const file_activity_v1_activity: GenFile = + /*@__PURE__*/ + fileDesc( + "ChphY3Rpdml0eS92MS9hY3Rpdml0eS5wcm90bxILYWN0aXZpdHkudjEizgMKDUFjdGl2aXR5RW50cnkSEAoIZXZlbnRfaWQYASABKAkSFgoOZXZlbnRfY2F0ZWdvcnkYAiABKAkSEgoKZXZlbnRfdHlwZRgDIAEoCRITCgtkZXNjcmlwdGlvbhgEIAEoCRIXCgpzY29wZV90eXBlGAUgASgJSACIAQESGAoLc2NvcGVfbGFiZWwYBiABKAlIAYgBARITCgtzY29wZV9jb3VudBgHIAEoBRISCgphY3Rvcl90eXBlGAggASgJEhQKB3VzZXJfaWQYCSABKAlIAogBARIVCgh1c2VybmFtZRgKIAEoCUgDiAEBEi4KCmNyZWF0ZWRfYXQYCyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCG1ldGFkYXRhGAwgASgLMhcuZ29vZ2xlLnByb3RvYnVmLlN0cnVjdEgEiAEBEg4KBnJlc3VsdBgNIAEoCRIaCg1lcnJvcl9tZXNzYWdlGA4gASgJSAWIAQFCDQoLX3Njb3BlX3R5cGVCDgoMX3Njb3BlX2xhYmVsQgoKCF91c2VyX2lkQgsKCV91c2VybmFtZUILCglfbWV0YWRhdGFCEAoOX2Vycm9yX21lc3NhZ2Ui2QEKDkFjdGl2aXR5RmlsdGVyEhgKEGV2ZW50X2NhdGVnb3JpZXMYASADKAkSEwoLZXZlbnRfdHlwZXMYAiADKAkSEAoIdXNlcl9pZHMYAyADKAkSLgoKc3RhcnRfdGltZRgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLAoIZW5kX3RpbWUYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhMKC3NlYXJjaF90ZXh0GAYgASgJEhMKC3Njb3BlX3R5cGVzGAcgAygJInYKFUxpc3RBY3Rpdml0aWVzUmVxdWVzdBIrCgZmaWx0ZXIYASABKAsyGy5hY3Rpdml0eS52MS5BY3Rpdml0eUZpbHRlchIcCglwYWdlX3NpemUYAiABKAVCCbpIBhoEGGQoARISCgpwYWdlX3Rva2VuGAMgASgJInYKFkxpc3RBY3Rpdml0aWVzUmVzcG9uc2USLgoKYWN0aXZpdGllcxgBIAMoCzIaLmFjdGl2aXR5LnYxLkFjdGl2aXR5RW50cnkSFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJEhMKC3RvdGFsX2NvdW50GAMgASgFIkYKF0V4cG9ydEFjdGl2aXRpZXNSZXF1ZXN0EisKBmZpbHRlchgBIAEoCzIbLmFjdGl2aXR5LnYxLkFjdGl2aXR5RmlsdGVyIikKGEV4cG9ydEFjdGl2aXRpZXNSZXNwb25zZRINCgVjaHVuaxgBIAEoDCIiCiBMaXN0QWN0aXZpdHlGaWx0ZXJPcHRpb25zUmVxdWVzdCKTAQohTGlzdEFjdGl2aXR5RmlsdGVyT3B0aW9uc1Jlc3BvbnNlEjEKC2V2ZW50X3R5cGVzGAEgAygLMhwuYWN0aXZpdHkudjEuRXZlbnRUeXBlT3B0aW9uEhMKC3Njb3BlX3R5cGVzGAIgAygJEiYKBXVzZXJzGAMgAygLMhcuYWN0aXZpdHkudjEuVXNlck9wdGlvbiI9Cg9FdmVudFR5cGVPcHRpb24SEgoKZXZlbnRfdHlwZRgBIAEoCRIWCg5ldmVudF9jYXRlZ29yeRgCIAEoCSIvCgpVc2VyT3B0aW9uEg8KB3VzZXJfaWQYASABKAkSEAoIdXNlcm5hbWUYAiABKAkyywIKD0FjdGl2aXR5U2VydmljZRJZCg5MaXN0QWN0aXZpdGllcxIiLmFjdGl2aXR5LnYxLkxpc3RBY3Rpdml0aWVzUmVxdWVzdBojLmFjdGl2aXR5LnYxLkxpc3RBY3Rpdml0aWVzUmVzcG9uc2USYQoQRXhwb3J0QWN0aXZpdGllcxIkLmFjdGl2aXR5LnYxLkV4cG9ydEFjdGl2aXRpZXNSZXF1ZXN0GiUuYWN0aXZpdHkudjEuRXhwb3J0QWN0aXZpdGllc1Jlc3BvbnNlMAESegoZTGlzdEFjdGl2aXR5RmlsdGVyT3B0aW9ucxItLmFjdGl2aXR5LnYxLkxpc3RBY3Rpdml0eUZpbHRlck9wdGlvbnNSZXF1ZXN0Gi4uYWN0aXZpdHkudjEuTGlzdEFjdGl2aXR5RmlsdGVyT3B0aW9uc1Jlc3BvbnNlQrgBCg9jb20uYWN0aXZpdHkudjFCDUFjdGl2aXR5UHJvdG9QAVpJZ2l0aHViLmNvbS9ibG9jay9wcm90by1mbGVldC9zZXJ2ZXIvZ2VuZXJhdGVkL2dycGMvYWN0aXZpdHkvdjE7YWN0aXZpdHl2MaICA0FYWKoCC0FjdGl2aXR5LlYxygILQWN0aXZpdHlcVjHiAhdBY3Rpdml0eVxWMVxHUEJNZXRhZGF0YeoCDEFjdGl2aXR5OjpWMWIGcHJvdG8z", + [file_google_protobuf_timestamp, file_google_protobuf_struct, file_buf_validate_validate], + ); + +/** + * A single recorded activity event + * + * @generated from message activity.v1.ActivityEntry + */ +export type ActivityEntry = Message<"activity.v1.ActivityEntry"> & { + /** + * Unique identifier for this activity event + * + * @generated from field: string event_id = 1; + */ + eventId: string; + + /** + * High-level category (e.g. "auth", "device_command", "fleet_management") + * + * @generated from field: string event_category = 2; + */ + eventCategory: string; + + /** + * Specific action type (e.g. "login", "reboot", "delete_miners") + * + * @generated from field: string event_type = 3; + */ + eventType: string; + + /** + * Human-readable description of the activity + * + * @generated from field: string description = 4; + */ + description: string; + + /** + * Type of scope affected (e.g. "group", "rack"), if applicable + * + * @generated from field: optional string scope_type = 5; + */ + scopeType?: string; + + /** + * Label of the scope (e.g. group/rack name), if applicable + * + * @generated from field: optional string scope_label = 6; + */ + scopeLabel?: string; + + /** + * Number of devices affected by the activity + * + * @generated from field: int32 scope_count = 7; + */ + scopeCount: number; + + /** + * Type of actor that performed the activity ("user", "system", or "scheduler") + * + * @generated from field: string actor_type = 8; + */ + actorType: string; + + /** + * External user ID of the actor, if performed by a user + * + * @generated from field: optional string user_id = 9; + */ + userId?: string; + + /** + * Username of the actor at the time of the activity, if performed by a user + * + * @generated from field: optional string username = 10; + */ + username?: string; + + /** + * Timestamp when the activity was recorded + * + * @generated from field: google.protobuf.Timestamp created_at = 11; + */ + createdAt?: Timestamp; + + /** + * Additional structured metadata associated with the activity + * + * @generated from field: optional google.protobuf.Struct metadata = 12; + */ + metadata?: JsonObject; + + /** + * Outcome of the activity ("success" or "failure") + * + * @generated from field: string result = 13; + */ + result: string; + + /** + * Error message for failed activities, if any + * + * @generated from field: optional string error_message = 14; + */ + errorMessage?: string; +}; + +/** + * Describes the message activity.v1.ActivityEntry. + * Use `create(ActivityEntrySchema)` to create a new message. + */ +export const ActivityEntrySchema: GenMessage = /*@__PURE__*/ messageDesc(file_activity_v1_activity, 0); + +/** + * Criteria for filtering activity entries + * + * @generated from message activity.v1.ActivityFilter + */ +export type ActivityFilter = Message<"activity.v1.ActivityFilter"> & { + /** + * Filter by event categories (empty = all categories) + * + * @generated from field: repeated string event_categories = 1; + */ + eventCategories: string[]; + + /** + * Filter by specific event types (empty = all types) + * + * @generated from field: repeated string event_types = 2; + */ + eventTypes: string[]; + + /** + * Filter by user IDs (empty = all users) + * + * @generated from field: repeated string user_ids = 3; + */ + userIds: string[]; + + /** + * Include activities at or after this time + * + * @generated from field: google.protobuf.Timestamp start_time = 4; + */ + startTime?: Timestamp; + + /** + * Include activities at or before this time + * + * @generated from field: google.protobuf.Timestamp end_time = 5; + */ + endTime?: Timestamp; + + /** + * Case-insensitive text search on activity descriptions + * + * @generated from field: string search_text = 6; + */ + searchText: string; + + /** + * Filter by scope types (empty = all scope types) + * + * @generated from field: repeated string scope_types = 7; + */ + scopeTypes: string[]; +}; + +/** + * Describes the message activity.v1.ActivityFilter. + * Use `create(ActivityFilterSchema)` to create a new message. + */ +export const ActivityFilterSchema: GenMessage = /*@__PURE__*/ messageDesc(file_activity_v1_activity, 1); + +/** + * Request for listing activities with pagination + * + * @generated from message activity.v1.ListActivitiesRequest + */ +export type ListActivitiesRequest = Message<"activity.v1.ListActivitiesRequest"> & { + /** + * Filter criteria for selecting activities + * + * @generated from field: activity.v1.ActivityFilter filter = 1; + */ + filter?: ActivityFilter; + + /** + * Maximum number of activities to return per page + * + * @generated from field: int32 page_size = 2; + */ + pageSize: number; + + /** + * Opaque token for fetching the next page of results + * + * @generated from field: string page_token = 3; + */ + pageToken: string; +}; + +/** + * Describes the message activity.v1.ListActivitiesRequest. + * Use `create(ListActivitiesRequestSchema)` to create a new message. + */ +export const ListActivitiesRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_activity_v1_activity, 2); + +/** + * Response containing a page of activities + * + * @generated from message activity.v1.ListActivitiesResponse + */ +export type ListActivitiesResponse = Message<"activity.v1.ListActivitiesResponse"> & { + /** + * Activity entries in the current page + * + * @generated from field: repeated activity.v1.ActivityEntry activities = 1; + */ + activities: ActivityEntry[]; + + /** + * Token for retrieving the next page, empty if no more results + * + * @generated from field: string next_page_token = 2; + */ + nextPageToken: string; + + /** + * Total number of activities matching the filter (returned on first page only) + * + * @generated from field: int32 total_count = 3; + */ + totalCount: number; +}; + +/** + * Describes the message activity.v1.ListActivitiesResponse. + * Use `create(ListActivitiesResponseSchema)` to create a new message. + */ +export const ListActivitiesResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_activity_v1_activity, 3); + +/** + * Request for exporting activities + * + * @generated from message activity.v1.ExportActivitiesRequest + */ +export type ExportActivitiesRequest = Message<"activity.v1.ExportActivitiesRequest"> & { + /** + * Filter criteria for selecting activities to export + * + * @generated from field: activity.v1.ActivityFilter filter = 1; + */ + filter?: ActivityFilter; +}; + +/** + * Describes the message activity.v1.ExportActivitiesRequest. + * Use `create(ExportActivitiesRequestSchema)` to create a new message. + */ +export const ExportActivitiesRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_activity_v1_activity, 4); + +/** + * Streamed chunk of exported activity data + * + * @generated from message activity.v1.ExportActivitiesResponse + */ +export type ExportActivitiesResponse = Message<"activity.v1.ExportActivitiesResponse"> & { + /** + * Raw bytes for a portion of the exported data + * + * @generated from field: bytes chunk = 1; + */ + chunk: Uint8Array; +}; + +/** + * Describes the message activity.v1.ExportActivitiesResponse. + * Use `create(ExportActivitiesResponseSchema)` to create a new message. + */ +export const ExportActivitiesResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_activity_v1_activity, 5); + +/** + * Request for fetching available filter options (org is derived from session) + * + * @generated from message activity.v1.ListActivityFilterOptionsRequest + */ +export type ListActivityFilterOptionsRequest = Message<"activity.v1.ListActivityFilterOptionsRequest"> & {}; + +/** + * Describes the message activity.v1.ListActivityFilterOptionsRequest. + * Use `create(ListActivityFilterOptionsRequestSchema)` to create a new message. + */ +export const ListActivityFilterOptionsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_activity_v1_activity, 6); + +/** + * Available options for building activity filters + * + * @generated from message activity.v1.ListActivityFilterOptionsResponse + */ +export type ListActivityFilterOptionsResponse = Message<"activity.v1.ListActivityFilterOptionsResponse"> & { + /** + * Available event type options + * + * @generated from field: repeated activity.v1.EventTypeOption event_types = 1; + */ + eventTypes: EventTypeOption[]; + + /** + * Available scope types + * + * @generated from field: repeated string scope_types = 2; + */ + scopeTypes: string[]; + + /** + * Available users + * + * @generated from field: repeated activity.v1.UserOption users = 3; + */ + users: UserOption[]; +}; + +/** + * Describes the message activity.v1.ListActivityFilterOptionsResponse. + * Use `create(ListActivityFilterOptionsResponseSchema)` to create a new message. + */ +export const ListActivityFilterOptionsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_activity_v1_activity, 7); + +/** + * An event type and its associated category + * + * @generated from message activity.v1.EventTypeOption + */ +export type EventTypeOption = Message<"activity.v1.EventTypeOption"> & { + /** + * Event type value + * + * @generated from field: string event_type = 1; + */ + eventType: string; + + /** + * Category this event type belongs to + * + * @generated from field: string event_category = 2; + */ + eventCategory: string; +}; + +/** + * Describes the message activity.v1.EventTypeOption. + * Use `create(EventTypeOptionSchema)` to create a new message. + */ +export const EventTypeOptionSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_activity_v1_activity, 8); + +/** + * A user that can be referenced in activity filters + * + * @generated from message activity.v1.UserOption + */ +export type UserOption = Message<"activity.v1.UserOption"> & { + /** + * External user identifier + * + * @generated from field: string user_id = 1; + */ + userId: string; + + /** + * Username + * + * @generated from field: string username = 2; + */ + username: string; +}; + +/** + * Describes the message activity.v1.UserOption. + * Use `create(UserOptionSchema)` to create a new message. + */ +export const UserOptionSchema: GenMessage = /*@__PURE__*/ messageDesc(file_activity_v1_activity, 9); + +/** + * ActivityService provides APIs for querying and exporting activity events + * and retrieving available filter options + * + * @generated from service activity.v1.ActivityService + */ +export const ActivityService: GenService<{ + /** + * Returns a paginated list of activity entries matching the supplied filter + * + * @generated from rpc activity.v1.ActivityService.ListActivities + */ + listActivities: { + methodKind: "unary"; + input: typeof ListActivitiesRequestSchema; + output: typeof ListActivitiesResponseSchema; + }; + /** + * Streams activity entries as serialized export data (CSV) + * + * @generated from rpc activity.v1.ActivityService.ExportActivities + */ + exportActivities: { + methodKind: "server_streaming"; + input: typeof ExportActivitiesRequestSchema; + output: typeof ExportActivitiesResponseSchema; + }; + /** + * Returns available values for activity filters (event types, scope types, users) + * + * @generated from rpc activity.v1.ActivityService.ListActivityFilterOptions + */ + listActivityFilterOptions: { + methodKind: "unary"; + input: typeof ListActivityFilterOptionsRequestSchema; + output: typeof ListActivityFilterOptionsResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_activity_v1_activity, 0); diff --git a/client/src/protoFleet/api/generated/apikey/v1/apikey_pb.ts b/client/src/protoFleet/api/generated/apikey/v1/apikey_pb.ts new file mode 100644 index 000000000..56dd8f894 --- /dev/null +++ b/client/src/protoFleet/api/generated/apikey/v1/apikey_pb.ts @@ -0,0 +1,237 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file apikey/v1/apikey.proto (package apikey.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import { file_buf_validate_validate } from "../../buf/validate/validate_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file apikey/v1/apikey.proto. + */ +export const file_apikey_v1_apikey: GenFile = + /*@__PURE__*/ + fileDesc( + "ChZhcGlrZXkvdjEvYXBpa2V5LnByb3RvEglhcGlrZXkudjEiXwoTQ3JlYXRlQXBpS2V5UmVxdWVzdBIYCgRuYW1lGAEgASgJQgq6SAdyBRABGP8BEi4KCmV4cGlyZXNfYXQYAiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIkwKFENyZWF0ZUFwaUtleVJlc3BvbnNlEg8KB2FwaV9rZXkYASABKAkSIwoEaW5mbxgCIAEoCzIVLmFwaWtleS52MS5BcGlLZXlJbmZvIhQKEkxpc3RBcGlLZXlzUmVxdWVzdCI+ChNMaXN0QXBpS2V5c1Jlc3BvbnNlEicKCGFwaV9rZXlzGAEgAygLMhUuYXBpa2V5LnYxLkFwaUtleUluZm8i4AEKCkFwaUtleUluZm8SDgoGa2V5X2lkGAEgASgJEgwKBG5hbWUYAiABKAkSDgoGcHJlZml4GAMgASgJEi4KCmNyZWF0ZWRfYXQYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCmV4cGlyZXNfYXQYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjAKDGxhc3RfdXNlZF9hdBgGIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASEgoKY3JlYXRlZF9ieRgHIAEoCSIuChNSZXZva2VBcGlLZXlSZXF1ZXN0EhcKBmtleV9pZBgBIAEoCUIHukgEcgIQASIWChRSZXZva2VBcGlLZXlSZXNwb25zZTL/AQoNQXBpS2V5U2VydmljZRJPCgxDcmVhdGVBcGlLZXkSHi5hcGlrZXkudjEuQ3JlYXRlQXBpS2V5UmVxdWVzdBofLmFwaWtleS52MS5DcmVhdGVBcGlLZXlSZXNwb25zZRJMCgtMaXN0QXBpS2V5cxIdLmFwaWtleS52MS5MaXN0QXBpS2V5c1JlcXVlc3QaHi5hcGlrZXkudjEuTGlzdEFwaUtleXNSZXNwb25zZRJPCgxSZXZva2VBcGlLZXkSHi5hcGlrZXkudjEuUmV2b2tlQXBpS2V5UmVxdWVzdBofLmFwaWtleS52MS5SZXZva2VBcGlLZXlSZXNwb25zZUKoAQoNY29tLmFwaWtleS52MUILQXBpa2V5UHJvdG9QAVpFZ2l0aHViLmNvbS9ibG9jay9wcm90by1mbGVldC9zZXJ2ZXIvZ2VuZXJhdGVkL2dycGMvYXBpa2V5L3YxO2FwaWtleXYxogIDQVhYqgIJQXBpa2V5LlYxygIJQXBpa2V5XFYx4gIVQXBpa2V5XFYxXEdQQk1ldGFkYXRh6gIKQXBpa2V5OjpWMWIGcHJvdG8z", + [file_google_protobuf_timestamp, file_buf_validate_validate], + ); + +/** + * @generated from message apikey.v1.CreateApiKeyRequest + */ +export type CreateApiKeyRequest = Message<"apikey.v1.CreateApiKeyRequest"> & { + /** + * Human-readable name for the API key + * + * @generated from field: string name = 1; + */ + name: string; + + /** + * Optional expiration timestamp. If not set, the key never expires. + * + * @generated from field: google.protobuf.Timestamp expires_at = 2; + */ + expiresAt?: Timestamp; +}; + +/** + * Describes the message apikey.v1.CreateApiKeyRequest. + * Use `create(CreateApiKeyRequestSchema)` to create a new message. + */ +export const CreateApiKeyRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_apikey_v1_apikey, 0); + +/** + * @generated from message apikey.v1.CreateApiKeyResponse + */ +export type CreateApiKeyResponse = Message<"apikey.v1.CreateApiKeyResponse"> & { + /** + * The full API key. This is shown ONCE and cannot be retrieved again. + * + * @generated from field: string api_key = 1; + */ + apiKey: string; + + /** + * API key metadata + * + * @generated from field: apikey.v1.ApiKeyInfo info = 2; + */ + info?: ApiKeyInfo; +}; + +/** + * Describes the message apikey.v1.CreateApiKeyResponse. + * Use `create(CreateApiKeyResponseSchema)` to create a new message. + */ +export const CreateApiKeyResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_apikey_v1_apikey, 1); + +/** + * @generated from message apikey.v1.ListApiKeysRequest + */ +export type ListApiKeysRequest = Message<"apikey.v1.ListApiKeysRequest"> & {}; + +/** + * Describes the message apikey.v1.ListApiKeysRequest. + * Use `create(ListApiKeysRequestSchema)` to create a new message. + */ +export const ListApiKeysRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_apikey_v1_apikey, 2); + +/** + * @generated from message apikey.v1.ListApiKeysResponse + */ +export type ListApiKeysResponse = Message<"apikey.v1.ListApiKeysResponse"> & { + /** + * @generated from field: repeated apikey.v1.ApiKeyInfo api_keys = 1; + */ + apiKeys: ApiKeyInfo[]; +}; + +/** + * Describes the message apikey.v1.ListApiKeysResponse. + * Use `create(ListApiKeysResponseSchema)` to create a new message. + */ +export const ListApiKeysResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_apikey_v1_apikey, 3); + +/** + * @generated from message apikey.v1.ApiKeyInfo + */ +export type ApiKeyInfo = Message<"apikey.v1.ApiKeyInfo"> & { + /** + * Unique identifier for this API key + * + * @generated from field: string key_id = 1; + */ + keyId: string; + + /** + * Human-readable name + * + * @generated from field: string name = 2; + */ + name: string; + + /** + * Key prefix for identification (e.g., "fleet_ab3f1e09...") + * + * @generated from field: string prefix = 3; + */ + prefix: string; + + /** + * Timestamp when the key was created + * + * @generated from field: google.protobuf.Timestamp created_at = 4; + */ + createdAt?: Timestamp; + + /** + * Optional expiration timestamp + * + * @generated from field: google.protobuf.Timestamp expires_at = 5; + */ + expiresAt?: Timestamp; + + /** + * Timestamp when the key was last used for authentication + * + * @generated from field: google.protobuf.Timestamp last_used_at = 6; + */ + lastUsedAt?: Timestamp; + + /** + * Username of the user who created the key + * + * @generated from field: string created_by = 7; + */ + createdBy: string; +}; + +/** + * Describes the message apikey.v1.ApiKeyInfo. + * Use `create(ApiKeyInfoSchema)` to create a new message. + */ +export const ApiKeyInfoSchema: GenMessage = /*@__PURE__*/ messageDesc(file_apikey_v1_apikey, 4); + +/** + * @generated from message apikey.v1.RevokeApiKeyRequest + */ +export type RevokeApiKeyRequest = Message<"apikey.v1.RevokeApiKeyRequest"> & { + /** + * ID of the API key to revoke + * + * @generated from field: string key_id = 1; + */ + keyId: string; +}; + +/** + * Describes the message apikey.v1.RevokeApiKeyRequest. + * Use `create(RevokeApiKeyRequestSchema)` to create a new message. + */ +export const RevokeApiKeyRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_apikey_v1_apikey, 5); + +/** + * @generated from message apikey.v1.RevokeApiKeyResponse + */ +export type RevokeApiKeyResponse = Message<"apikey.v1.RevokeApiKeyResponse"> & {}; + +/** + * Describes the message apikey.v1.RevokeApiKeyResponse. + * Use `create(RevokeApiKeyResponseSchema)` to create a new message. + */ +export const RevokeApiKeyResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_apikey_v1_apikey, 6); + +/** + * ApiKeyService provides management of API keys for programmatic gRPC access + * + * @generated from service apikey.v1.ApiKeyService + */ +export const ApiKeyService: GenService<{ + /** + * CreateApiKey creates a new API key for the authenticated user's organization. + * The full key is returned once in the response and cannot be retrieved again. + * + * @generated from rpc apikey.v1.ApiKeyService.CreateApiKey + */ + createApiKey: { + methodKind: "unary"; + input: typeof CreateApiKeyRequestSchema; + output: typeof CreateApiKeyResponseSchema; + }; + /** + * ListApiKeys returns all active (non-revoked) API keys for the organization. + * + * @generated from rpc apikey.v1.ApiKeyService.ListApiKeys + */ + listApiKeys: { + methodKind: "unary"; + input: typeof ListApiKeysRequestSchema; + output: typeof ListApiKeysResponseSchema; + }; + /** + * RevokeApiKey permanently revokes an API key. The key cannot be used after revocation. + * + * @generated from rpc apikey.v1.ApiKeyService.RevokeApiKey + */ + revokeApiKey: { + methodKind: "unary"; + input: typeof RevokeApiKeyRequestSchema; + output: typeof RevokeApiKeyResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_apikey_v1_apikey, 0); diff --git a/client/src/protoFleet/api/generated/auth/v1/auth_pb.ts b/client/src/protoFleet/api/generated/auth/v1/auth_pb.ts new file mode 100644 index 000000000..e2335b171 --- /dev/null +++ b/client/src/protoFleet/api/generated/auth/v1/auth_pb.ts @@ -0,0 +1,660 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file auth/v1/auth.proto (package auth.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file auth/v1/auth.proto. + */ +export const file_auth_v1_auth: GenFile = + /*@__PURE__*/ + fileDesc( + "ChJhdXRoL3YxL2F1dGgucHJvdG8SB2F1dGgudjEiOQoTQXV0aGVudGljYXRlUmVxdWVzdBIQCgh1c2VybmFtZRgBIAEoCRIQCghwYXNzd29yZBgCIAEoCSJUChRBdXRoZW50aWNhdGVSZXNwb25zZRIkCgl1c2VyX2luZm8YASABKAsyES5hdXRoLnYxLlVzZXJJbmZvEhYKDnNlc3Npb25fZXhwaXJ5GAIgASgDIg8KDUxvZ291dFJlcXVlc3QiEAoOTG9nb3V0UmVzcG9uc2UiRwoVVXBkYXRlUGFzc3dvcmRSZXF1ZXN0EhgKEGN1cnJlbnRfcGFzc3dvcmQYASABKAkSFAoMbmV3X3Bhc3N3b3JkGAIgASgJIhgKFlVwZGF0ZVBhc3N3b3JkUmVzcG9uc2UiKQoVVXBkYXRlVXNlcm5hbWVSZXF1ZXN0EhAKCHVzZXJuYW1lGAEgASgJIhgKFlVwZGF0ZVVzZXJuYW1lUmVzcG9uc2UiGQoXR2V0VXNlckF1ZGl0SW5mb1JlcXVlc3QiSAoNVXNlckF1ZGl0SW5mbxI3ChNwYXNzd29yZF91cGRhdGVkX2F0GAEgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCJAChhHZXRVc2VyQXVkaXRJbmZvUmVzcG9uc2USJAoEaW5mbxgBIAEoCzIWLmF1dGgudjEuVXNlckF1ZGl0SW5mbyIlChFDcmVhdGVVc2VyUmVxdWVzdBIQCgh1c2VybmFtZRgBIAEoCSJTChJDcmVhdGVVc2VyUmVzcG9uc2USDwoHdXNlcl9pZBgBIAEoCRIQCgh1c2VybmFtZRgCIAEoCRIaChJ0ZW1wb3JhcnlfcGFzc3dvcmQYAyABKAkiEgoQTGlzdFVzZXJzUmVxdWVzdCLJAQoIVXNlckluZm8SDwoHdXNlcl9pZBgBIAEoCRIQCgh1c2VybmFtZRgCIAEoCRI3ChNwYXNzd29yZF91cGRhdGVkX2F0GAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIxCg1sYXN0X2xvZ2luX2F0GAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIMCgRyb2xlGAUgASgJEiAKGHJlcXVpcmVzX3Bhc3N3b3JkX2NoYW5nZRgGIAEoCCI1ChFMaXN0VXNlcnNSZXNwb25zZRIgCgV1c2VycxgBIAMoCzIRLmF1dGgudjEuVXNlckluZm8iKwoYUmVzZXRVc2VyUGFzc3dvcmRSZXF1ZXN0Eg8KB3VzZXJfaWQYASABKAkiNwoZUmVzZXRVc2VyUGFzc3dvcmRSZXNwb25zZRIaChJ0ZW1wb3JhcnlfcGFzc3dvcmQYASABKAkiKAoVRGVhY3RpdmF0ZVVzZXJSZXF1ZXN0Eg8KB3VzZXJfaWQYASABKAkiGAoWRGVhY3RpdmF0ZVVzZXJSZXNwb25zZSI+ChhWZXJpZnlDcmVkZW50aWFsc1JlcXVlc3QSEAoIdXNlcm5hbWUYASABKAkSEAoIcGFzc3dvcmQYAiABKAkiGwoZVmVyaWZ5Q3JlZGVudGlhbHNSZXNwb25zZSp2ChVBdXRoZW50aWNhdGVFcnJvckNvZGUSJwojQVVUSEVOVElDQVRFX0VSUk9SX0NPREVfVU5TUEVDSUZJRUQQABI0CjBBVVRIRU5USUNBVEVfRVJST1JfQ09ERV9JTlZBTElEX1VTRVJfT1JfUEFTU1dPUkQQASq8AQoXVXBkYXRlUGFzc3dvcmRFcnJvckNvZGUSKgomVVBEQVRFX1BBU1NXT1JEX0VSUk9SX0NPREVfVU5TUEVDSUZJRUQQABIzCi9VUERBVEVfUEFTU1dPUkRfRVJST1JfQ09ERV9JTlZBTElEX09MRF9QQVNTV09SRBABEkAKPFVQREFURV9QQVNTV09SRF9FUlJPUl9DT0RFX05FV19QQVNTV09SRF9TQU1FX0FTX09MRF9QQVNTV09SRBACKtkBChdVc2VyTWFuYWdlbWVudEVycm9yQ29kZRIqCiZVU0VSX01BTkFHRU1FTlRfRVJST1JfQ09ERV9VTlNQRUNJRklFRBAAEisKJ1VTRVJfTUFOQUdFTUVOVF9FUlJPUl9DT0RFX1VOQVVUSE9SSVpFRBABEi4KKlVTRVJfTUFOQUdFTUVOVF9FUlJPUl9DT0RFX1VTRVJOQU1FX0VYSVNUUxACEjUKMVVTRVJfTUFOQUdFTUVOVF9FUlJPUl9DT0RFX0NBTk5PVF9ERUFDVElWQVRFX1NFTEYQAzKqBgoLQXV0aFNlcnZpY2USSwoMQXV0aGVudGljYXRlEhwuYXV0aC52MS5BdXRoZW50aWNhdGVSZXF1ZXN0Gh0uYXV0aC52MS5BdXRoZW50aWNhdGVSZXNwb25zZRI5CgZMb2dvdXQSFi5hdXRoLnYxLkxvZ291dFJlcXVlc3QaFy5hdXRoLnYxLkxvZ291dFJlc3BvbnNlElEKDlVwZGF0ZVBhc3N3b3JkEh4uYXV0aC52MS5VcGRhdGVQYXNzd29yZFJlcXVlc3QaHy5hdXRoLnYxLlVwZGF0ZVBhc3N3b3JkUmVzcG9uc2USUQoOVXBkYXRlVXNlcm5hbWUSHi5hdXRoLnYxLlVwZGF0ZVVzZXJuYW1lUmVxdWVzdBofLmF1dGgudjEuVXBkYXRlVXNlcm5hbWVSZXNwb25zZRJXChBHZXRVc2VyQXVkaXRJbmZvEiAuYXV0aC52MS5HZXRVc2VyQXVkaXRJbmZvUmVxdWVzdBohLmF1dGgudjEuR2V0VXNlckF1ZGl0SW5mb1Jlc3BvbnNlEkUKCkNyZWF0ZVVzZXISGi5hdXRoLnYxLkNyZWF0ZVVzZXJSZXF1ZXN0GhsuYXV0aC52MS5DcmVhdGVVc2VyUmVzcG9uc2USQgoJTGlzdFVzZXJzEhkuYXV0aC52MS5MaXN0VXNlcnNSZXF1ZXN0GhouYXV0aC52MS5MaXN0VXNlcnNSZXNwb25zZRJaChFSZXNldFVzZXJQYXNzd29yZBIhLmF1dGgudjEuUmVzZXRVc2VyUGFzc3dvcmRSZXF1ZXN0GiIuYXV0aC52MS5SZXNldFVzZXJQYXNzd29yZFJlc3BvbnNlElEKDkRlYWN0aXZhdGVVc2VyEh4uYXV0aC52MS5EZWFjdGl2YXRlVXNlclJlcXVlc3QaHy5hdXRoLnYxLkRlYWN0aXZhdGVVc2VyUmVzcG9uc2USWgoRVmVyaWZ5Q3JlZGVudGlhbHMSIS5hdXRoLnYxLlZlcmlmeUNyZWRlbnRpYWxzUmVxdWVzdBoiLmF1dGgudjEuVmVyaWZ5Q3JlZGVudGlhbHNSZXNwb25zZUKYAQoLY29tLmF1dGgudjFCCUF1dGhQcm90b1ABWkFnaXRodWIuY29tL2Jsb2NrL3Byb3RvLWZsZWV0L3NlcnZlci9nZW5lcmF0ZWQvZ3JwYy9hdXRoL3YxO2F1dGh2MaICA0FYWKoCB0F1dGguVjHKAgdBdXRoXFYx4gITQXV0aFxWMVxHUEJNZXRhZGF0YeoCCEF1dGg6OlYxYgZwcm90bzM", + [file_google_protobuf_timestamp], + ); + +/** + * @generated from message auth.v1.AuthenticateRequest + */ +export type AuthenticateRequest = Message<"auth.v1.AuthenticateRequest"> & { + /** + * Username of the user attempting to authenticate + * + * @generated from field: string username = 1; + */ + username: string; + + /** + * Password for the user account + * + * @generated from field: string password = 2; + */ + password: string; +}; + +/** + * Describes the message auth.v1.AuthenticateRequest. + * Use `create(AuthenticateRequestSchema)` to create a new message. + */ +export const AuthenticateRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 0); + +/** + * @generated from message auth.v1.AuthenticateResponse + */ +export type AuthenticateResponse = Message<"auth.v1.AuthenticateResponse"> & { + /** + * Authenticated user information + * Contains user_id, username, role, password change requirements, and audit timestamps + * + * @generated from field: auth.v1.UserInfo user_info = 1; + */ + userInfo?: UserInfo; + + /** + * Unix timestamp (in seconds) indicating when the session will expire + * Session expiry is extended on each request (sliding window) + * + * @generated from field: int64 session_expiry = 2; + */ + sessionExpiry: bigint; +}; + +/** + * Describes the message auth.v1.AuthenticateResponse. + * Use `create(AuthenticateResponseSchema)` to create a new message. + */ +export const AuthenticateResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 1); + +/** + * @generated from message auth.v1.LogoutRequest + */ +export type LogoutRequest = Message<"auth.v1.LogoutRequest"> & {}; + +/** + * Describes the message auth.v1.LogoutRequest. + * Use `create(LogoutRequestSchema)` to create a new message. + */ +export const LogoutRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_auth_v1_auth, 2); + +/** + * @generated from message auth.v1.LogoutResponse + */ +export type LogoutResponse = Message<"auth.v1.LogoutResponse"> & {}; + +/** + * Describes the message auth.v1.LogoutResponse. + * Use `create(LogoutResponseSchema)` to create a new message. + */ +export const LogoutResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_auth_v1_auth, 3); + +/** + * @generated from message auth.v1.UpdatePasswordRequest + */ +export type UpdatePasswordRequest = Message<"auth.v1.UpdatePasswordRequest"> & { + /** + * Current password for the user account + * Must match the user's existing password + * + * @generated from field: string current_password = 1; + */ + currentPassword: string; + + /** + * New password for the user account + * + * @generated from field: string new_password = 2; + */ + newPassword: string; +}; + +/** + * Describes the message auth.v1.UpdatePasswordRequest. + * Use `create(UpdatePasswordRequestSchema)` to create a new message. + */ +export const UpdatePasswordRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 4); + +/** + * Empty response as success/failure is indicated by gRPC status + * A successful response indicates the password was changed + * + * @generated from message auth.v1.UpdatePasswordResponse + */ +export type UpdatePasswordResponse = Message<"auth.v1.UpdatePasswordResponse"> & {}; + +/** + * Describes the message auth.v1.UpdatePasswordResponse. + * Use `create(UpdatePasswordResponseSchema)` to create a new message. + */ +export const UpdatePasswordResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 5); + +/** + * @generated from message auth.v1.UpdateUsernameRequest + */ +export type UpdateUsernameRequest = Message<"auth.v1.UpdateUsernameRequest"> & { + /** + * @generated from field: string username = 1; + */ + username: string; +}; + +/** + * Describes the message auth.v1.UpdateUsernameRequest. + * Use `create(UpdateUsernameRequestSchema)` to create a new message. + */ +export const UpdateUsernameRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 6); + +/** + * @generated from message auth.v1.UpdateUsernameResponse + */ +export type UpdateUsernameResponse = Message<"auth.v1.UpdateUsernameResponse"> & {}; + +/** + * Describes the message auth.v1.UpdateUsernameResponse. + * Use `create(UpdateUsernameResponseSchema)` to create a new message. + */ +export const UpdateUsernameResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 7); + +/** + * @generated from message auth.v1.GetUserAuditInfoRequest + */ +export type GetUserAuditInfoRequest = Message<"auth.v1.GetUserAuditInfoRequest"> & {}; + +/** + * Describes the message auth.v1.GetUserAuditInfoRequest. + * Use `create(GetUserAuditInfoRequestSchema)` to create a new message. + */ +export const GetUserAuditInfoRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 8); + +/** + * @generated from message auth.v1.UserAuditInfo + */ +export type UserAuditInfo = Message<"auth.v1.UserAuditInfo"> & { + /** + * @generated from field: google.protobuf.Timestamp password_updated_at = 1; + */ + passwordUpdatedAt?: Timestamp; +}; + +/** + * Describes the message auth.v1.UserAuditInfo. + * Use `create(UserAuditInfoSchema)` to create a new message. + */ +export const UserAuditInfoSchema: GenMessage = /*@__PURE__*/ messageDesc(file_auth_v1_auth, 9); + +/** + * @generated from message auth.v1.GetUserAuditInfoResponse + */ +export type GetUserAuditInfoResponse = Message<"auth.v1.GetUserAuditInfoResponse"> & { + /** + * @generated from field: auth.v1.UserAuditInfo info = 1; + */ + info?: UserAuditInfo; +}; + +/** + * Describes the message auth.v1.GetUserAuditInfoResponse. + * Use `create(GetUserAuditInfoResponseSchema)` to create a new message. + */ +export const GetUserAuditInfoResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 10); + +/** + * @generated from message auth.v1.CreateUserRequest + */ +export type CreateUserRequest = Message<"auth.v1.CreateUserRequest"> & { + /** + * Username for the new user account + * Must be unique within the system + * + * @generated from field: string username = 1; + */ + username: string; +}; + +/** + * Describes the message auth.v1.CreateUserRequest. + * Use `create(CreateUserRequestSchema)` to create a new message. + */ +export const CreateUserRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_auth_v1_auth, 11); + +/** + * @generated from message auth.v1.CreateUserResponse + */ +export type CreateUserResponse = Message<"auth.v1.CreateUserResponse"> & { + /** + * Unique identifier for the created user + * + * @generated from field: string user_id = 1; + */ + userId: string; + + /** + * Username of the created user + * + * @generated from field: string username = 2; + */ + username: string; + + /** + * Temporary password generated by the system + * This is only returned once and will not be accessible again + * + * @generated from field: string temporary_password = 3; + */ + temporaryPassword: string; +}; + +/** + * Describes the message auth.v1.CreateUserResponse. + * Use `create(CreateUserResponseSchema)` to create a new message. + */ +export const CreateUserResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 12); + +/** + * @generated from message auth.v1.ListUsersRequest + */ +export type ListUsersRequest = Message<"auth.v1.ListUsersRequest"> & {}; + +/** + * Describes the message auth.v1.ListUsersRequest. + * Use `create(ListUsersRequestSchema)` to create a new message. + */ +export const ListUsersRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_auth_v1_auth, 13); + +/** + * @generated from message auth.v1.UserInfo + */ +export type UserInfo = Message<"auth.v1.UserInfo"> & { + /** + * Unique identifier for the user + * + * @generated from field: string user_id = 1; + */ + userId: string; + + /** + * Username of the user + * + * @generated from field: string username = 2; + */ + username: string; + + /** + * Timestamp when the user's password was last updated + * + * @generated from field: google.protobuf.Timestamp password_updated_at = 3; + */ + passwordUpdatedAt?: Timestamp; + + /** + * Timestamp when the user last logged in + * May be null if the user has never logged in + * + * @generated from field: google.protobuf.Timestamp last_login_at = 4; + */ + lastLoginAt?: Timestamp; + + /** + * Role name + * + * @generated from field: string role = 5; + */ + role: string; + + /** + * Indicates whether the user must change their password + * + * @generated from field: bool requires_password_change = 6; + */ + requiresPasswordChange: boolean; +}; + +/** + * Describes the message auth.v1.UserInfo. + * Use `create(UserInfoSchema)` to create a new message. + */ +export const UserInfoSchema: GenMessage = /*@__PURE__*/ messageDesc(file_auth_v1_auth, 14); + +/** + * @generated from message auth.v1.ListUsersResponse + */ +export type ListUsersResponse = Message<"auth.v1.ListUsersResponse"> & { + /** + * @generated from field: repeated auth.v1.UserInfo users = 1; + */ + users: UserInfo[]; +}; + +/** + * Describes the message auth.v1.ListUsersResponse. + * Use `create(ListUsersResponseSchema)` to create a new message. + */ +export const ListUsersResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_auth_v1_auth, 15); + +/** + * @generated from message auth.v1.ResetUserPasswordRequest + */ +export type ResetUserPasswordRequest = Message<"auth.v1.ResetUserPasswordRequest"> & { + /** + * Unique identifier of the user whose password should be reset + * + * @generated from field: string user_id = 1; + */ + userId: string; +}; + +/** + * Describes the message auth.v1.ResetUserPasswordRequest. + * Use `create(ResetUserPasswordRequestSchema)` to create a new message. + */ +export const ResetUserPasswordRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 16); + +/** + * @generated from message auth.v1.ResetUserPasswordResponse + */ +export type ResetUserPasswordResponse = Message<"auth.v1.ResetUserPasswordResponse"> & { + /** + * New temporary password generated by the system + * This is only returned once and will not be accessible again + * + * @generated from field: string temporary_password = 1; + */ + temporaryPassword: string; +}; + +/** + * Describes the message auth.v1.ResetUserPasswordResponse. + * Use `create(ResetUserPasswordResponseSchema)` to create a new message. + */ +export const ResetUserPasswordResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 17); + +/** + * @generated from message auth.v1.DeactivateUserRequest + */ +export type DeactivateUserRequest = Message<"auth.v1.DeactivateUserRequest"> & { + /** + * Unique identifier of the user to deactivate + * + * @generated from field: string user_id = 1; + */ + userId: string; +}; + +/** + * Describes the message auth.v1.DeactivateUserRequest. + * Use `create(DeactivateUserRequestSchema)` to create a new message. + */ +export const DeactivateUserRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 18); + +/** + * @generated from message auth.v1.DeactivateUserResponse + */ +export type DeactivateUserResponse = Message<"auth.v1.DeactivateUserResponse"> & {}; + +/** + * Describes the message auth.v1.DeactivateUserResponse. + * Use `create(DeactivateUserResponseSchema)` to create a new message. + */ +export const DeactivateUserResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 19); + +/** + * @generated from message auth.v1.VerifyCredentialsRequest + */ +export type VerifyCredentialsRequest = Message<"auth.v1.VerifyCredentialsRequest"> & { + /** + * Username — must match the currently authenticated session user + * + * @generated from field: string username = 1; + */ + username: string; + + /** + * Password for the currently authenticated user + * + * @generated from field: string password = 2; + */ + password: string; +}; + +/** + * Describes the message auth.v1.VerifyCredentialsRequest. + * Use `create(VerifyCredentialsRequestSchema)` to create a new message. + */ +export const VerifyCredentialsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 20); + +/** + * @generated from message auth.v1.VerifyCredentialsResponse + */ +export type VerifyCredentialsResponse = Message<"auth.v1.VerifyCredentialsResponse"> & {}; + +/** + * Describes the message auth.v1.VerifyCredentialsResponse. + * Use `create(VerifyCredentialsResponseSchema)` to create a new message. + */ +export const VerifyCredentialsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 21); + +/** + * @generated from enum auth.v1.AuthenticateErrorCode + */ +export enum AuthenticateErrorCode { + /** + * @generated from enum value: AUTHENTICATE_ERROR_CODE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: AUTHENTICATE_ERROR_CODE_INVALID_USER_OR_PASSWORD = 1; + */ + INVALID_USER_OR_PASSWORD = 1, +} + +/** + * Describes the enum auth.v1.AuthenticateErrorCode. + */ +export const AuthenticateErrorCodeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_auth_v1_auth, 0); + +/** + * @generated from enum auth.v1.UpdatePasswordErrorCode + */ +export enum UpdatePasswordErrorCode { + /** + * @generated from enum value: UPDATE_PASSWORD_ERROR_CODE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: UPDATE_PASSWORD_ERROR_CODE_INVALID_OLD_PASSWORD = 1; + */ + INVALID_OLD_PASSWORD = 1, + + /** + * @generated from enum value: UPDATE_PASSWORD_ERROR_CODE_NEW_PASSWORD_SAME_AS_OLD_PASSWORD = 2; + */ + NEW_PASSWORD_SAME_AS_OLD_PASSWORD = 2, +} + +/** + * Describes the enum auth.v1.UpdatePasswordErrorCode. + */ +export const UpdatePasswordErrorCodeSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_auth_v1_auth, 1); + +/** + * @generated from enum auth.v1.UserManagementErrorCode + */ +export enum UserManagementErrorCode { + /** + * @generated from enum value: USER_MANAGEMENT_ERROR_CODE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: USER_MANAGEMENT_ERROR_CODE_UNAUTHORIZED = 1; + */ + UNAUTHORIZED = 1, + + /** + * @generated from enum value: USER_MANAGEMENT_ERROR_CODE_USERNAME_EXISTS = 2; + */ + USERNAME_EXISTS = 2, + + /** + * @generated from enum value: USER_MANAGEMENT_ERROR_CODE_CANNOT_DEACTIVATE_SELF = 3; + */ + CANNOT_DEACTIVATE_SELF = 3, +} + +/** + * Describes the enum auth.v1.UserManagementErrorCode. + */ +export const UserManagementErrorCodeSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_auth_v1_auth, 2); + +/** + * AuthService provides authentication and user credential management functionality + * + * @generated from service auth.v1.AuthService + */ +export const AuthService: GenService<{ + /** + * Authenticate validates user credentials and creates a session + * Returns session information and sets a session cookie for subsequent requests + * + * @generated from rpc auth.v1.AuthService.Authenticate + */ + authenticate: { + methodKind: "unary"; + input: typeof AuthenticateRequestSchema; + output: typeof AuthenticateResponseSchema; + }; + /** + * Logout invalidates the current session + * + * @generated from rpc auth.v1.AuthService.Logout + */ + logout: { + methodKind: "unary"; + input: typeof LogoutRequestSchema; + output: typeof LogoutResponseSchema; + }; + /** + * UpdatePassword changes a user's password after verifying their current password + * Returns an error if the current password is incorrect + * The user must be authenticated to use this endpoint + * + * @generated from rpc auth.v1.AuthService.UpdatePassword + */ + updatePassword: { + methodKind: "unary"; + input: typeof UpdatePasswordRequestSchema; + output: typeof UpdatePasswordResponseSchema; + }; + /** + * @generated from rpc auth.v1.AuthService.UpdateUsername + */ + updateUsername: { + methodKind: "unary"; + input: typeof UpdateUsernameRequestSchema; + output: typeof UpdateUsernameResponseSchema; + }; + /** + * @generated from rpc auth.v1.AuthService.GetUserAuditInfo + */ + getUserAuditInfo: { + methodKind: "unary"; + input: typeof GetUserAuditInfoRequestSchema; + output: typeof GetUserAuditInfoResponseSchema; + }; + /** + * CreateUser creates a new user account with a system-generated temporary password (Super Admin only) + * The temporary password is only returned once and must be shared with the new user + * + * @generated from rpc auth.v1.AuthService.CreateUser + */ + createUser: { + methodKind: "unary"; + input: typeof CreateUserRequestSchema; + output: typeof CreateUserResponseSchema; + }; + /** + * ListUsers returns all active users in the organization (Super Admin only) + * + * @generated from rpc auth.v1.AuthService.ListUsers + */ + listUsers: { + methodKind: "unary"; + input: typeof ListUsersRequestSchema; + output: typeof ListUsersResponseSchema; + }; + /** + * ResetUserPassword generates a new temporary password for an existing user (Super Admin only) + * The temporary password is only returned once and must be shared with the user + * + * @generated from rpc auth.v1.AuthService.ResetUserPassword + */ + resetUserPassword: { + methodKind: "unary"; + input: typeof ResetUserPasswordRequestSchema; + output: typeof ResetUserPasswordResponseSchema; + }; + /** + * DeactivateUser performs a soft delete on a user account (Super Admin only) + * Users cannot deactivate themselves + * + * @generated from rpc auth.v1.AuthService.DeactivateUser + */ + deactivateUser: { + methodKind: "unary"; + input: typeof DeactivateUserRequestSchema; + output: typeof DeactivateUserResponseSchema; + }; + /** + * VerifyCredentials verifies the provided username and password match the current session user, + * without creating a new session. Used for step-up authentication on sensitive operations + * (e.g., editing pools, managing security). Both username and password must match the + * authenticated session user; mismatched or invalid credentials are rejected. + * + * @generated from rpc auth.v1.AuthService.VerifyCredentials + */ + verifyCredentials: { + methodKind: "unary"; + input: typeof VerifyCredentialsRequestSchema; + output: typeof VerifyCredentialsResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_auth_v1_auth, 0); diff --git a/client/src/protoFleet/api/generated/buf/validate/validate_pb.ts b/client/src/protoFleet/api/generated/buf/validate/validate_pb.ts new file mode 100644 index 000000000..8eb2aee79 --- /dev/null +++ b/client/src/protoFleet/api/generated/buf/validate/validate_pb.ts @@ -0,0 +1,4801 @@ +// Copyright 2023-2025 Buf Technologies, Inc. +// +// 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. + +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file buf/validate/validate.proto (package buf.validate, syntax proto2) +/* eslint-disable */ + +import type { GenEnum, GenExtension, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, extDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import type { + Duration, + FieldDescriptorProto_Type, + FieldOptions, + MessageOptions, + OneofOptions, + Timestamp, +} from "@bufbuild/protobuf/wkt"; +import { + file_google_protobuf_descriptor, + file_google_protobuf_duration, + file_google_protobuf_timestamp, +} from "@bufbuild/protobuf/wkt"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file buf/validate/validate.proto. + */ +export const file_buf_validate_validate: GenFile = + /*@__PURE__*/ + fileDesc( + "ChtidWYvdmFsaWRhdGUvdmFsaWRhdGUucHJvdG8SDGJ1Zi52YWxpZGF0ZSI3CgRSdWxlEgoKAmlkGAEgASgJEg8KB21lc3NhZ2UYAiABKAkSEgoKZXhwcmVzc2lvbhgDIAEoCSJBCgxNZXNzYWdlUnVsZXMSEAoIZGlzYWJsZWQYASABKAgSHwoDY2VsGAMgAygLMhIuYnVmLnZhbGlkYXRlLlJ1bGUiHgoKT25lb2ZSdWxlcxIQCghyZXF1aXJlZBgBIAEoCCK/CAoKRmllbGRSdWxlcxIfCgNjZWwYFyADKAsyEi5idWYudmFsaWRhdGUuUnVsZRIQCghyZXF1aXJlZBgZIAEoCBIkCgZpZ25vcmUYGyABKA4yFC5idWYudmFsaWRhdGUuSWdub3JlEikKBWZsb2F0GAEgASgLMhguYnVmLnZhbGlkYXRlLkZsb2F0UnVsZXNIABIrCgZkb3VibGUYAiABKAsyGS5idWYudmFsaWRhdGUuRG91YmxlUnVsZXNIABIpCgVpbnQzMhgDIAEoCzIYLmJ1Zi52YWxpZGF0ZS5JbnQzMlJ1bGVzSAASKQoFaW50NjQYBCABKAsyGC5idWYudmFsaWRhdGUuSW50NjRSdWxlc0gAEisKBnVpbnQzMhgFIAEoCzIZLmJ1Zi52YWxpZGF0ZS5VSW50MzJSdWxlc0gAEisKBnVpbnQ2NBgGIAEoCzIZLmJ1Zi52YWxpZGF0ZS5VSW50NjRSdWxlc0gAEisKBnNpbnQzMhgHIAEoCzIZLmJ1Zi52YWxpZGF0ZS5TSW50MzJSdWxlc0gAEisKBnNpbnQ2NBgIIAEoCzIZLmJ1Zi52YWxpZGF0ZS5TSW50NjRSdWxlc0gAEi0KB2ZpeGVkMzIYCSABKAsyGi5idWYudmFsaWRhdGUuRml4ZWQzMlJ1bGVzSAASLQoHZml4ZWQ2NBgKIAEoCzIaLmJ1Zi52YWxpZGF0ZS5GaXhlZDY0UnVsZXNIABIvCghzZml4ZWQzMhgLIAEoCzIbLmJ1Zi52YWxpZGF0ZS5TRml4ZWQzMlJ1bGVzSAASLwoIc2ZpeGVkNjQYDCABKAsyGy5idWYudmFsaWRhdGUuU0ZpeGVkNjRSdWxlc0gAEicKBGJvb2wYDSABKAsyFy5idWYudmFsaWRhdGUuQm9vbFJ1bGVzSAASKwoGc3RyaW5nGA4gASgLMhkuYnVmLnZhbGlkYXRlLlN0cmluZ1J1bGVzSAASKQoFYnl0ZXMYDyABKAsyGC5idWYudmFsaWRhdGUuQnl0ZXNSdWxlc0gAEicKBGVudW0YECABKAsyFy5idWYudmFsaWRhdGUuRW51bVJ1bGVzSAASLwoIcmVwZWF0ZWQYEiABKAsyGy5idWYudmFsaWRhdGUuUmVwZWF0ZWRSdWxlc0gAEiUKA21hcBgTIAEoCzIWLmJ1Zi52YWxpZGF0ZS5NYXBSdWxlc0gAEiUKA2FueRgUIAEoCzIWLmJ1Zi52YWxpZGF0ZS5BbnlSdWxlc0gAEi8KCGR1cmF0aW9uGBUgASgLMhsuYnVmLnZhbGlkYXRlLkR1cmF0aW9uUnVsZXNIABIxCgl0aW1lc3RhbXAYFiABKAsyHC5idWYudmFsaWRhdGUuVGltZXN0YW1wUnVsZXNIAEIGCgR0eXBlSgQIGBAZSgQIGhAbUgdza2lwcGVkUgxpZ25vcmVfZW1wdHkiUwoPUHJlZGVmaW5lZFJ1bGVzEh8KA2NlbBgBIAMoCzISLmJ1Zi52YWxpZGF0ZS5SdWxlSgQIGBAZSgQIGhAbUhNza2lwcGVkaWdub3JlX2VtcHR5ItoXCgpGbG9hdFJ1bGVzEoMBCgVjb25zdBgBIAEoAkJ0wkhxCm8KC2Zsb2F0LmNvbnN0GmB0aGlzICE9IGdldEZpZWxkKHJ1bGVzLCAnY29uc3QnKSA/ICd2YWx1ZSBtdXN0IGVxdWFsICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnY29uc3QnKV0pIDogJycSnwEKAmx0GAIgASgCQpABwkiMAQqJAQoIZmxvYXQubHQafSFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiAodGhpcy5pc05hbigpIHx8IHRoaXMgPj0gcnVsZXMubHQpPyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMubHRdKSA6ICcnSAASrwEKA2x0ZRgDIAEoAkKfAcJImwEKmAEKCWZsb2F0Lmx0ZRqKASFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiAodGhpcy5pc05hbigpIHx8IHRoaXMgPiBydWxlcy5sdGUpPyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMubHRlXSkgOiAnJ0gAEu8HCgJndBgEIAEoAkLgB8JI3AcKjQEKCGZsb2F0Lmd0GoABIWhhcyhydWxlcy5sdCkgJiYgIWhhcyhydWxlcy5sdGUpICYmICh0aGlzLmlzTmFuKCkgfHwgdGhpcyA8PSBydWxlcy5ndCk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndF0pIDogJycKwwEKC2Zsb2F0Lmd0X2x0GrMBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndCAmJiAodGhpcy5pc05hbigpIHx8IHRoaXMgPj0gcnVsZXMubHQgfHwgdGhpcyA8PSBydWxlcy5ndCk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBhbmQgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdF0pIDogJycKzQEKFWZsb2F0Lmd0X2x0X2V4Y2x1c2l2ZRqzAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPCBydWxlcy5ndCAmJiAodGhpcy5pc05hbigpIHx8IChydWxlcy5sdCA8PSB0aGlzICYmIHRoaXMgPD0gcnVsZXMuZ3QpKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCtMBCgxmbG9hdC5ndF9sdGUawgFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3QgJiYgKHRoaXMuaXNOYW4oKSB8fCB0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJwrdAQoWZmxvYXQuZ3RfbHRlX2V4Y2x1c2l2ZRrCAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ICYmICh0aGlzLmlzTmFuKCkgfHwgKHJ1bGVzLmx0ZSA8IHRoaXMgJiYgdGhpcyA8PSBydWxlcy5ndCkpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgb3IgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdGVdKSA6ICcnSAESuggKA2d0ZRgFIAEoAkKqCMJIpggKmwEKCWZsb2F0Lmd0ZRqNASFoYXMocnVsZXMubHQpICYmICFoYXMocnVsZXMubHRlKSAmJiAodGhpcy5pc05hbigpIHx8IHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlXSkgOiAnJwrSAQoMZmxvYXQuZ3RlX2x0GsEBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndGUgJiYgKHRoaXMuaXNOYW4oKSB8fCB0aGlzID49IHJ1bGVzLmx0IHx8IHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrcAQoWZmxvYXQuZ3RlX2x0X2V4Y2x1c2l2ZRrBAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPCBydWxlcy5ndGUgJiYgKHRoaXMuaXNOYW4oKSB8fCAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBvciBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdF0pIDogJycK4gEKDWZsb2F0Lmd0ZV9sdGUa0AFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3RlICYmICh0aGlzLmlzTmFuKCkgfHwgdGhpcyA+IHJ1bGVzLmx0ZSB8fCB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIGFuZCBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdGVdKSA6ICcnCuwBChdmbG9hdC5ndGVfbHRlX2V4Y2x1c2l2ZRrQAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ZSAmJiAodGhpcy5pc05hbigpIHx8IChydWxlcy5sdGUgPCB0aGlzICYmIHRoaXMgPCBydWxlcy5ndGUpKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJydIARJ/CgJpbhgGIAMoAkJzwkhwCm4KCGZsb2F0LmluGmIhKHRoaXMgaW4gZ2V0RmllbGQocnVsZXMsICdpbicpKSA/ICd2YWx1ZSBtdXN0IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdpbicpXSkgOiAnJxJ2CgZub3RfaW4YByADKAJCZsJIYwphCgxmbG9hdC5ub3RfaW4aUXRoaXMgaW4gcnVsZXMubm90X2luID8gJ3ZhbHVlIG11c3Qgbm90IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbcnVsZXMubm90X2luXSkgOiAnJxJ1CgZmaW5pdGUYCCABKAhCZcJIYgpgCgxmbG9hdC5maW5pdGUaUHJ1bGVzLmZpbml0ZSA/ICh0aGlzLmlzTmFuKCkgfHwgdGhpcy5pc0luZigpID8gJ3ZhbHVlIG11c3QgYmUgZmluaXRlJyA6ICcnKSA6ICcnEisKB2V4YW1wbGUYCSADKAJCGsJIFwoVCg1mbG9hdC5leGFtcGxlGgR0cnVlKgkI6AcQgICAgAJCCwoJbGVzc190aGFuQg4KDGdyZWF0ZXJfdGhhbiLtFwoLRG91YmxlUnVsZXMShAEKBWNvbnN0GAEgASgBQnXCSHIKcAoMZG91YmxlLmNvbnN0GmB0aGlzICE9IGdldEZpZWxkKHJ1bGVzLCAnY29uc3QnKSA/ICd2YWx1ZSBtdXN0IGVxdWFsICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnY29uc3QnKV0pIDogJycSoAEKAmx0GAIgASgBQpEBwkiNAQqKAQoJZG91YmxlLmx0Gn0haGFzKHJ1bGVzLmd0ZSkgJiYgIWhhcyhydWxlcy5ndCkgJiYgKHRoaXMuaXNOYW4oKSB8fCB0aGlzID49IHJ1bGVzLmx0KT8gJ3ZhbHVlIG11c3QgYmUgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmx0XSkgOiAnJ0gAErABCgNsdGUYAyABKAFCoAHCSJwBCpkBCgpkb3VibGUubHRlGooBIWhhcyhydWxlcy5ndGUpICYmICFoYXMocnVsZXMuZ3QpICYmICh0aGlzLmlzTmFuKCkgfHwgdGhpcyA+IHJ1bGVzLmx0ZSk/ICd2YWx1ZSBtdXN0IGJlIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5sdGVdKSA6ICcnSAAS9AcKAmd0GAQgASgBQuUHwkjhBwqOAQoJZG91YmxlLmd0GoABIWhhcyhydWxlcy5sdCkgJiYgIWhhcyhydWxlcy5sdGUpICYmICh0aGlzLmlzTmFuKCkgfHwgdGhpcyA8PSBydWxlcy5ndCk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndF0pIDogJycKxAEKDGRvdWJsZS5ndF9sdBqzAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPj0gcnVsZXMuZ3QgJiYgKHRoaXMuaXNOYW4oKSB8fCB0aGlzID49IHJ1bGVzLmx0IHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCs4BChZkb3VibGUuZ3RfbHRfZXhjbHVzaXZlGrMBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ICYmICh0aGlzLmlzTmFuKCkgfHwgKHJ1bGVzLmx0IDw9IHRoaXMgJiYgdGhpcyA8PSBydWxlcy5ndCkpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgb3IgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdF0pIDogJycK1AEKDWRvdWJsZS5ndF9sdGUawgFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3QgJiYgKHRoaXMuaXNOYW4oKSB8fCB0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJwreAQoXZG91YmxlLmd0X2x0ZV9leGNsdXNpdmUawgFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndCAmJiAodGhpcy5pc05hbigpIHx8IChydWxlcy5sdGUgPCB0aGlzICYmIHRoaXMgPD0gcnVsZXMuZ3QpKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJ0gBEr8ICgNndGUYBSABKAFCrwjCSKsICpwBCgpkb3VibGUuZ3RlGo0BIWhhcyhydWxlcy5sdCkgJiYgIWhhcyhydWxlcy5sdGUpICYmICh0aGlzLmlzTmFuKCkgfHwgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGVdKSA6ICcnCtMBCg1kb3VibGUuZ3RlX2x0GsEBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndGUgJiYgKHRoaXMuaXNOYW4oKSB8fCB0aGlzID49IHJ1bGVzLmx0IHx8IHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrdAQoXZG91YmxlLmd0ZV9sdF9leGNsdXNpdmUawQFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0IDwgcnVsZXMuZ3RlICYmICh0aGlzLmlzTmFuKCkgfHwgKHJ1bGVzLmx0IDw9IHRoaXMgJiYgdGhpcyA8IHJ1bGVzLmd0ZSkpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgb3IgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRdKSA6ICcnCuMBCg5kb3VibGUuZ3RlX2x0ZRrQAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA+PSBydWxlcy5ndGUgJiYgKHRoaXMuaXNOYW4oKSB8fCB0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJycK7QEKGGRvdWJsZS5ndGVfbHRlX2V4Y2x1c2l2ZRrQAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ZSAmJiAodGhpcy5pc05hbigpIHx8IChydWxlcy5sdGUgPCB0aGlzICYmIHRoaXMgPCBydWxlcy5ndGUpKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJydIARKAAQoCaW4YBiADKAFCdMJIcQpvCglkb3VibGUuaW4aYiEodGhpcyBpbiBnZXRGaWVsZChydWxlcywgJ2luJykpID8gJ3ZhbHVlIG11c3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2luJyldKSA6ICcnEncKBm5vdF9pbhgHIAMoAUJnwkhkCmIKDWRvdWJsZS5ub3RfaW4aUXRoaXMgaW4gcnVsZXMubm90X2luID8gJ3ZhbHVlIG11c3Qgbm90IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbcnVsZXMubm90X2luXSkgOiAnJxJ2CgZmaW5pdGUYCCABKAhCZsJIYwphCg1kb3VibGUuZmluaXRlGlBydWxlcy5maW5pdGUgPyAodGhpcy5pc05hbigpIHx8IHRoaXMuaXNJbmYoKSA/ICd2YWx1ZSBtdXN0IGJlIGZpbml0ZScgOiAnJykgOiAnJxIsCgdleGFtcGxlGAkgAygBQhvCSBgKFgoOZG91YmxlLmV4YW1wbGUaBHRydWUqCQjoBxCAgICAAkILCglsZXNzX3RoYW5CDgoMZ3JlYXRlcl90aGFuIowVCgpJbnQzMlJ1bGVzEoMBCgVjb25zdBgBIAEoBUJ0wkhxCm8KC2ludDMyLmNvbnN0GmB0aGlzICE9IGdldEZpZWxkKHJ1bGVzLCAnY29uc3QnKSA/ICd2YWx1ZSBtdXN0IGVxdWFsICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnY29uc3QnKV0pIDogJycSigEKAmx0GAIgASgFQnzCSHkKdwoIaW50MzIubHQaayFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID49IHJ1bGVzLmx0PyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMubHRdKSA6ICcnSAASnAEKA2x0ZRgDIAEoBUKMAcJIiAEKhQEKCWludDMyLmx0ZRp4IWhhcyhydWxlcy5ndGUpICYmICFoYXMocnVsZXMuZ3QpICYmIHRoaXMgPiBydWxlcy5sdGU/ICd2YWx1ZSBtdXN0IGJlIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5sdGVdKSA6ICcnSAASlwcKAmd0GAQgASgFQogHwkiEBwp6CghpbnQzMi5ndBpuIWhhcyhydWxlcy5sdCkgJiYgIWhhcyhydWxlcy5sdGUpICYmIHRoaXMgPD0gcnVsZXMuZ3Q/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndF0pIDogJycKswEKC2ludDMyLmd0X2x0GqMBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndCAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwq7AQoVaW50MzIuZ3RfbHRfZXhjbHVzaXZlGqEBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ICYmIChydWxlcy5sdCA8PSB0aGlzICYmIHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgb3IgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdF0pIDogJycKwwEKDGludDMyLmd0X2x0ZRqyAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA+PSBydWxlcy5ndCAmJiAodGhpcyA+IHJ1bGVzLmx0ZSB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0ZV0pIDogJycKywEKFmludDMyLmd0X2x0ZV9leGNsdXNpdmUasAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndCAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJ0gBEuMHCgNndGUYBSABKAVC0wfCSM8HCogBCglpbnQzMi5ndGUaeyFoYXMocnVsZXMubHQpICYmICFoYXMocnVsZXMubHRlKSAmJiB0aGlzIDwgcnVsZXMuZ3RlPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlXSkgOiAnJwrCAQoMaW50MzIuZ3RlX2x0GrEBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndGUgJiYgKHRoaXMgPj0gcnVsZXMubHQgfHwgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBhbmQgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRdKSA6ICcnCsoBChZpbnQzMi5ndGVfbHRfZXhjbHVzaXZlGq8BaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrSAQoNaW50MzIuZ3RlX2x0ZRrAAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA+PSBydWxlcy5ndGUgJiYgKHRoaXMgPiBydWxlcy5sdGUgfHwgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBhbmQgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRlXSkgOiAnJwraAQoXaW50MzIuZ3RlX2x0ZV9leGNsdXNpdmUavgFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndGUgJiYgKHJ1bGVzLmx0ZSA8IHRoaXMgJiYgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBvciBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdGVdKSA6ICcnSAESfwoCaW4YBiADKAVCc8JIcApuCghpbnQzMi5pbhpiISh0aGlzIGluIGdldEZpZWxkKHJ1bGVzLCAnaW4nKSkgPyAndmFsdWUgbXVzdCBiZSBpbiBsaXN0ICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnaW4nKV0pIDogJycSdgoGbm90X2luGAcgAygFQmbCSGMKYQoMaW50MzIubm90X2luGlF0aGlzIGluIHJ1bGVzLm5vdF9pbiA/ICd2YWx1ZSBtdXN0IG5vdCBiZSBpbiBsaXN0ICVzJy5mb3JtYXQoW3J1bGVzLm5vdF9pbl0pIDogJycSKwoHZXhhbXBsZRgIIAMoBUIawkgXChUKDWludDMyLmV4YW1wbGUaBHRydWUqCQjoBxCAgICAAkILCglsZXNzX3RoYW5CDgoMZ3JlYXRlcl90aGFuIowVCgpJbnQ2NFJ1bGVzEoMBCgVjb25zdBgBIAEoA0J0wkhxCm8KC2ludDY0LmNvbnN0GmB0aGlzICE9IGdldEZpZWxkKHJ1bGVzLCAnY29uc3QnKSA/ICd2YWx1ZSBtdXN0IGVxdWFsICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnY29uc3QnKV0pIDogJycSigEKAmx0GAIgASgDQnzCSHkKdwoIaW50NjQubHQaayFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID49IHJ1bGVzLmx0PyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMubHRdKSA6ICcnSAASnAEKA2x0ZRgDIAEoA0KMAcJIiAEKhQEKCWludDY0Lmx0ZRp4IWhhcyhydWxlcy5ndGUpICYmICFoYXMocnVsZXMuZ3QpICYmIHRoaXMgPiBydWxlcy5sdGU/ICd2YWx1ZSBtdXN0IGJlIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5sdGVdKSA6ICcnSAASlwcKAmd0GAQgASgDQogHwkiEBwp6CghpbnQ2NC5ndBpuIWhhcyhydWxlcy5sdCkgJiYgIWhhcyhydWxlcy5sdGUpICYmIHRoaXMgPD0gcnVsZXMuZ3Q/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndF0pIDogJycKswEKC2ludDY0Lmd0X2x0GqMBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndCAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwq7AQoVaW50NjQuZ3RfbHRfZXhjbHVzaXZlGqEBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ICYmIChydWxlcy5sdCA8PSB0aGlzICYmIHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgb3IgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdF0pIDogJycKwwEKDGludDY0Lmd0X2x0ZRqyAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA+PSBydWxlcy5ndCAmJiAodGhpcyA+IHJ1bGVzLmx0ZSB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0ZV0pIDogJycKywEKFmludDY0Lmd0X2x0ZV9leGNsdXNpdmUasAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndCAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJ0gBEuMHCgNndGUYBSABKANC0wfCSM8HCogBCglpbnQ2NC5ndGUaeyFoYXMocnVsZXMubHQpICYmICFoYXMocnVsZXMubHRlKSAmJiB0aGlzIDwgcnVsZXMuZ3RlPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlXSkgOiAnJwrCAQoMaW50NjQuZ3RlX2x0GrEBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndGUgJiYgKHRoaXMgPj0gcnVsZXMubHQgfHwgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBhbmQgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRdKSA6ICcnCsoBChZpbnQ2NC5ndGVfbHRfZXhjbHVzaXZlGq8BaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrSAQoNaW50NjQuZ3RlX2x0ZRrAAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA+PSBydWxlcy5ndGUgJiYgKHRoaXMgPiBydWxlcy5sdGUgfHwgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBhbmQgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRlXSkgOiAnJwraAQoXaW50NjQuZ3RlX2x0ZV9leGNsdXNpdmUavgFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndGUgJiYgKHJ1bGVzLmx0ZSA8IHRoaXMgJiYgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBvciBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdGVdKSA6ICcnSAESfwoCaW4YBiADKANCc8JIcApuCghpbnQ2NC5pbhpiISh0aGlzIGluIGdldEZpZWxkKHJ1bGVzLCAnaW4nKSkgPyAndmFsdWUgbXVzdCBiZSBpbiBsaXN0ICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnaW4nKV0pIDogJycSdgoGbm90X2luGAcgAygDQmbCSGMKYQoMaW50NjQubm90X2luGlF0aGlzIGluIHJ1bGVzLm5vdF9pbiA/ICd2YWx1ZSBtdXN0IG5vdCBiZSBpbiBsaXN0ICVzJy5mb3JtYXQoW3J1bGVzLm5vdF9pbl0pIDogJycSKwoHZXhhbXBsZRgJIAMoA0IawkgXChUKDWludDY0LmV4YW1wbGUaBHRydWUqCQjoBxCAgICAAkILCglsZXNzX3RoYW5CDgoMZ3JlYXRlcl90aGFuIp4VCgtVSW50MzJSdWxlcxKEAQoFY29uc3QYASABKA1CdcJIcgpwCgx1aW50MzIuY29uc3QaYHRoaXMgIT0gZ2V0RmllbGQocnVsZXMsICdjb25zdCcpID8gJ3ZhbHVlIG11c3QgZXF1YWwgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdjb25zdCcpXSkgOiAnJxKLAQoCbHQYAiABKA1CfcJIegp4Cgl1aW50MzIubHQaayFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID49IHJ1bGVzLmx0PyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMubHRdKSA6ICcnSAASnQEKA2x0ZRgDIAEoDUKNAcJIiQEKhgEKCnVpbnQzMi5sdGUaeCFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID4gcnVsZXMubHRlPyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMubHRlXSkgOiAnJ0gAEpwHCgJndBgEIAEoDUKNB8JIiQcKewoJdWludDMyLmd0Gm4haGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8PSBydWxlcy5ndD8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0XSkgOiAnJwq0AQoMdWludDMyLmd0X2x0GqMBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndCAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwq8AQoWdWludDMyLmd0X2x0X2V4Y2x1c2l2ZRqhAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPCBydWxlcy5ndCAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCsQBCg11aW50MzIuZ3RfbHRlGrIBaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlID49IHJ1bGVzLmd0ICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJwrMAQoXdWludDMyLmd0X2x0ZV9leGNsdXNpdmUasAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndCAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJ0gBEugHCgNndGUYBSABKA1C2AfCSNQHCokBCgp1aW50MzIuZ3RlGnshaGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8IHJ1bGVzLmd0ZT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZV0pIDogJycKwwEKDXVpbnQzMi5ndGVfbHQasQFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0ID49IHJ1bGVzLmd0ZSAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdF0pIDogJycKywEKF3VpbnQzMi5ndGVfbHRfZXhjbHVzaXZlGq8BaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrTAQoOdWludDMyLmd0ZV9sdGUawAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3RlICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJycK2wEKGHVpbnQzMi5ndGVfbHRlX2V4Y2x1c2l2ZRq+AWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJydIARKAAQoCaW4YBiADKA1CdMJIcQpvCgl1aW50MzIuaW4aYiEodGhpcyBpbiBnZXRGaWVsZChydWxlcywgJ2luJykpID8gJ3ZhbHVlIG11c3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2luJyldKSA6ICcnEncKBm5vdF9pbhgHIAMoDUJnwkhkCmIKDXVpbnQzMi5ub3RfaW4aUXRoaXMgaW4gcnVsZXMubm90X2luID8gJ3ZhbHVlIG11c3Qgbm90IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbcnVsZXMubm90X2luXSkgOiAnJxIsCgdleGFtcGxlGAggAygNQhvCSBgKFgoOdWludDMyLmV4YW1wbGUaBHRydWUqCQjoBxCAgICAAkILCglsZXNzX3RoYW5CDgoMZ3JlYXRlcl90aGFuIp4VCgtVSW50NjRSdWxlcxKEAQoFY29uc3QYASABKARCdcJIcgpwCgx1aW50NjQuY29uc3QaYHRoaXMgIT0gZ2V0RmllbGQocnVsZXMsICdjb25zdCcpID8gJ3ZhbHVlIG11c3QgZXF1YWwgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdjb25zdCcpXSkgOiAnJxKLAQoCbHQYAiABKARCfcJIegp4Cgl1aW50NjQubHQaayFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID49IHJ1bGVzLmx0PyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMubHRdKSA6ICcnSAASnQEKA2x0ZRgDIAEoBEKNAcJIiQEKhgEKCnVpbnQ2NC5sdGUaeCFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID4gcnVsZXMubHRlPyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMubHRlXSkgOiAnJ0gAEpwHCgJndBgEIAEoBEKNB8JIiQcKewoJdWludDY0Lmd0Gm4haGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8PSBydWxlcy5ndD8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0XSkgOiAnJwq0AQoMdWludDY0Lmd0X2x0GqMBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndCAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwq8AQoWdWludDY0Lmd0X2x0X2V4Y2x1c2l2ZRqhAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPCBydWxlcy5ndCAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCsQBCg11aW50NjQuZ3RfbHRlGrIBaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlID49IHJ1bGVzLmd0ICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJwrMAQoXdWludDY0Lmd0X2x0ZV9leGNsdXNpdmUasAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndCAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJ0gBEugHCgNndGUYBSABKARC2AfCSNQHCokBCgp1aW50NjQuZ3RlGnshaGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8IHJ1bGVzLmd0ZT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZV0pIDogJycKwwEKDXVpbnQ2NC5ndGVfbHQasQFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0ID49IHJ1bGVzLmd0ZSAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdF0pIDogJycKywEKF3VpbnQ2NC5ndGVfbHRfZXhjbHVzaXZlGq8BaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrTAQoOdWludDY0Lmd0ZV9sdGUawAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3RlICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJycK2wEKGHVpbnQ2NC5ndGVfbHRlX2V4Y2x1c2l2ZRq+AWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJydIARKAAQoCaW4YBiADKARCdMJIcQpvCgl1aW50NjQuaW4aYiEodGhpcyBpbiBnZXRGaWVsZChydWxlcywgJ2luJykpID8gJ3ZhbHVlIG11c3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2luJyldKSA6ICcnEncKBm5vdF9pbhgHIAMoBEJnwkhkCmIKDXVpbnQ2NC5ub3RfaW4aUXRoaXMgaW4gcnVsZXMubm90X2luID8gJ3ZhbHVlIG11c3Qgbm90IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbcnVsZXMubm90X2luXSkgOiAnJxIsCgdleGFtcGxlGAggAygEQhvCSBgKFgoOdWludDY0LmV4YW1wbGUaBHRydWUqCQjoBxCAgICAAkILCglsZXNzX3RoYW5CDgoMZ3JlYXRlcl90aGFuIp4VCgtTSW50MzJSdWxlcxKEAQoFY29uc3QYASABKBFCdcJIcgpwCgxzaW50MzIuY29uc3QaYHRoaXMgIT0gZ2V0RmllbGQocnVsZXMsICdjb25zdCcpID8gJ3ZhbHVlIG11c3QgZXF1YWwgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdjb25zdCcpXSkgOiAnJxKLAQoCbHQYAiABKBFCfcJIegp4CglzaW50MzIubHQaayFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID49IHJ1bGVzLmx0PyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMubHRdKSA6ICcnSAASnQEKA2x0ZRgDIAEoEUKNAcJIiQEKhgEKCnNpbnQzMi5sdGUaeCFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID4gcnVsZXMubHRlPyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMubHRlXSkgOiAnJ0gAEpwHCgJndBgEIAEoEUKNB8JIiQcKewoJc2ludDMyLmd0Gm4haGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8PSBydWxlcy5ndD8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0XSkgOiAnJwq0AQoMc2ludDMyLmd0X2x0GqMBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndCAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwq8AQoWc2ludDMyLmd0X2x0X2V4Y2x1c2l2ZRqhAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPCBydWxlcy5ndCAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCsQBCg1zaW50MzIuZ3RfbHRlGrIBaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlID49IHJ1bGVzLmd0ICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJwrMAQoXc2ludDMyLmd0X2x0ZV9leGNsdXNpdmUasAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndCAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJ0gBEugHCgNndGUYBSABKBFC2AfCSNQHCokBCgpzaW50MzIuZ3RlGnshaGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8IHJ1bGVzLmd0ZT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZV0pIDogJycKwwEKDXNpbnQzMi5ndGVfbHQasQFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0ID49IHJ1bGVzLmd0ZSAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdF0pIDogJycKywEKF3NpbnQzMi5ndGVfbHRfZXhjbHVzaXZlGq8BaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrTAQoOc2ludDMyLmd0ZV9sdGUawAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3RlICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJycK2wEKGHNpbnQzMi5ndGVfbHRlX2V4Y2x1c2l2ZRq+AWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJydIARKAAQoCaW4YBiADKBFCdMJIcQpvCglzaW50MzIuaW4aYiEodGhpcyBpbiBnZXRGaWVsZChydWxlcywgJ2luJykpID8gJ3ZhbHVlIG11c3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2luJyldKSA6ICcnEncKBm5vdF9pbhgHIAMoEUJnwkhkCmIKDXNpbnQzMi5ub3RfaW4aUXRoaXMgaW4gcnVsZXMubm90X2luID8gJ3ZhbHVlIG11c3Qgbm90IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbcnVsZXMubm90X2luXSkgOiAnJxIsCgdleGFtcGxlGAggAygRQhvCSBgKFgoOc2ludDMyLmV4YW1wbGUaBHRydWUqCQjoBxCAgICAAkILCglsZXNzX3RoYW5CDgoMZ3JlYXRlcl90aGFuIp4VCgtTSW50NjRSdWxlcxKEAQoFY29uc3QYASABKBJCdcJIcgpwCgxzaW50NjQuY29uc3QaYHRoaXMgIT0gZ2V0RmllbGQocnVsZXMsICdjb25zdCcpID8gJ3ZhbHVlIG11c3QgZXF1YWwgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdjb25zdCcpXSkgOiAnJxKLAQoCbHQYAiABKBJCfcJIegp4CglzaW50NjQubHQaayFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID49IHJ1bGVzLmx0PyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMubHRdKSA6ICcnSAASnQEKA2x0ZRgDIAEoEkKNAcJIiQEKhgEKCnNpbnQ2NC5sdGUaeCFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID4gcnVsZXMubHRlPyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMubHRlXSkgOiAnJ0gAEpwHCgJndBgEIAEoEkKNB8JIiQcKewoJc2ludDY0Lmd0Gm4haGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8PSBydWxlcy5ndD8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0XSkgOiAnJwq0AQoMc2ludDY0Lmd0X2x0GqMBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndCAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwq8AQoWc2ludDY0Lmd0X2x0X2V4Y2x1c2l2ZRqhAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPCBydWxlcy5ndCAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCsQBCg1zaW50NjQuZ3RfbHRlGrIBaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlID49IHJ1bGVzLmd0ICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJwrMAQoXc2ludDY0Lmd0X2x0ZV9leGNsdXNpdmUasAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndCAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJ0gBEugHCgNndGUYBSABKBJC2AfCSNQHCokBCgpzaW50NjQuZ3RlGnshaGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8IHJ1bGVzLmd0ZT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZV0pIDogJycKwwEKDXNpbnQ2NC5ndGVfbHQasQFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0ID49IHJ1bGVzLmd0ZSAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdF0pIDogJycKywEKF3NpbnQ2NC5ndGVfbHRfZXhjbHVzaXZlGq8BaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrTAQoOc2ludDY0Lmd0ZV9sdGUawAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3RlICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJycK2wEKGHNpbnQ2NC5ndGVfbHRlX2V4Y2x1c2l2ZRq+AWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJydIARKAAQoCaW4YBiADKBJCdMJIcQpvCglzaW50NjQuaW4aYiEodGhpcyBpbiBnZXRGaWVsZChydWxlcywgJ2luJykpID8gJ3ZhbHVlIG11c3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2luJyldKSA6ICcnEncKBm5vdF9pbhgHIAMoEkJnwkhkCmIKDXNpbnQ2NC5ub3RfaW4aUXRoaXMgaW4gcnVsZXMubm90X2luID8gJ3ZhbHVlIG11c3Qgbm90IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbcnVsZXMubm90X2luXSkgOiAnJxIsCgdleGFtcGxlGAggAygSQhvCSBgKFgoOc2ludDY0LmV4YW1wbGUaBHRydWUqCQjoBxCAgICAAkILCglsZXNzX3RoYW5CDgoMZ3JlYXRlcl90aGFuIq8VCgxGaXhlZDMyUnVsZXMShQEKBWNvbnN0GAEgASgHQnbCSHMKcQoNZml4ZWQzMi5jb25zdBpgdGhpcyAhPSBnZXRGaWVsZChydWxlcywgJ2NvbnN0JykgPyAndmFsdWUgbXVzdCBlcXVhbCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2NvbnN0JyldKSA6ICcnEowBCgJsdBgCIAEoB0J+wkh7CnkKCmZpeGVkMzIubHQaayFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID49IHJ1bGVzLmx0PyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMubHRdKSA6ICcnSAASngEKA2x0ZRgDIAEoB0KOAcJIigEKhwEKC2ZpeGVkMzIubHRlGnghaGFzKHJ1bGVzLmd0ZSkgJiYgIWhhcyhydWxlcy5ndCkgJiYgdGhpcyA+IHJ1bGVzLmx0ZT8gJ3ZhbHVlIG11c3QgYmUgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmx0ZV0pIDogJydIABKhBwoCZ3QYBCABKAdCkgfCSI4HCnwKCmZpeGVkMzIuZ3QabiFoYXMocnVsZXMubHQpICYmICFoYXMocnVsZXMubHRlKSAmJiB0aGlzIDw9IHJ1bGVzLmd0PyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RdKSA6ICcnCrUBCg1maXhlZDMyLmd0X2x0GqMBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndCAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwq9AQoXZml4ZWQzMi5ndF9sdF9leGNsdXNpdmUaoQFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0IDwgcnVsZXMuZ3QgJiYgKHJ1bGVzLmx0IDw9IHRoaXMgJiYgdGhpcyA8PSBydWxlcy5ndCk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBvciBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwrFAQoOZml4ZWQzMi5ndF9sdGUasgFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3QgJiYgKHRoaXMgPiBydWxlcy5sdGUgfHwgdGhpcyA8PSBydWxlcy5ndCk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBhbmQgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdGVdKSA6ICcnCs0BChhmaXhlZDMyLmd0X2x0ZV9leGNsdXNpdmUasAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndCAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJ0gBEu0HCgNndGUYBSABKAdC3QfCSNkHCooBCgtmaXhlZDMyLmd0ZRp7IWhhcyhydWxlcy5sdCkgJiYgIWhhcyhydWxlcy5sdGUpICYmIHRoaXMgPCBydWxlcy5ndGU/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGVdKSA6ICcnCsQBCg5maXhlZDMyLmd0ZV9sdBqxAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPj0gcnVsZXMuZ3RlICYmICh0aGlzID49IHJ1bGVzLmx0IHx8IHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrMAQoYZml4ZWQzMi5ndGVfbHRfZXhjbHVzaXZlGq8BaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrUAQoPZml4ZWQzMi5ndGVfbHRlGsABaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlID49IHJ1bGVzLmd0ZSAmJiAodGhpcyA+IHJ1bGVzLmx0ZSB8fCB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIGFuZCBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdGVdKSA6ICcnCtwBChlmaXhlZDMyLmd0ZV9sdGVfZXhjbHVzaXZlGr4BaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlIDwgcnVsZXMuZ3RlICYmIChydWxlcy5sdGUgPCB0aGlzICYmIHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgb3IgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRlXSkgOiAnJ0gBEoEBCgJpbhgGIAMoB0J1wkhyCnAKCmZpeGVkMzIuaW4aYiEodGhpcyBpbiBnZXRGaWVsZChydWxlcywgJ2luJykpID8gJ3ZhbHVlIG11c3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2luJyldKSA6ICcnEngKBm5vdF9pbhgHIAMoB0JowkhlCmMKDmZpeGVkMzIubm90X2luGlF0aGlzIGluIHJ1bGVzLm5vdF9pbiA/ICd2YWx1ZSBtdXN0IG5vdCBiZSBpbiBsaXN0ICVzJy5mb3JtYXQoW3J1bGVzLm5vdF9pbl0pIDogJycSLQoHZXhhbXBsZRgIIAMoB0IcwkgZChcKD2ZpeGVkMzIuZXhhbXBsZRoEdHJ1ZSoJCOgHEICAgIACQgsKCWxlc3NfdGhhbkIOCgxncmVhdGVyX3RoYW4irxUKDEZpeGVkNjRSdWxlcxKFAQoFY29uc3QYASABKAZCdsJIcwpxCg1maXhlZDY0LmNvbnN0GmB0aGlzICE9IGdldEZpZWxkKHJ1bGVzLCAnY29uc3QnKSA/ICd2YWx1ZSBtdXN0IGVxdWFsICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnY29uc3QnKV0pIDogJycSjAEKAmx0GAIgASgGQn7CSHsKeQoKZml4ZWQ2NC5sdBprIWhhcyhydWxlcy5ndGUpICYmICFoYXMocnVsZXMuZ3QpICYmIHRoaXMgPj0gcnVsZXMubHQ/ICd2YWx1ZSBtdXN0IGJlIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5sdF0pIDogJydIABKeAQoDbHRlGAMgASgGQo4BwkiKAQqHAQoLZml4ZWQ2NC5sdGUaeCFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID4gcnVsZXMubHRlPyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMubHRlXSkgOiAnJ0gAEqEHCgJndBgEIAEoBkKSB8JIjgcKfAoKZml4ZWQ2NC5ndBpuIWhhcyhydWxlcy5sdCkgJiYgIWhhcyhydWxlcy5sdGUpICYmIHRoaXMgPD0gcnVsZXMuZ3Q/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndF0pIDogJycKtQEKDWZpeGVkNjQuZ3RfbHQaowFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0ID49IHJ1bGVzLmd0ICYmICh0aGlzID49IHJ1bGVzLmx0IHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCr0BChdmaXhlZDY0Lmd0X2x0X2V4Y2x1c2l2ZRqhAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPCBydWxlcy5ndCAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCsUBCg5maXhlZDY0Lmd0X2x0ZRqyAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA+PSBydWxlcy5ndCAmJiAodGhpcyA+IHJ1bGVzLmx0ZSB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0ZV0pIDogJycKzQEKGGZpeGVkNjQuZ3RfbHRlX2V4Y2x1c2l2ZRqwAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ICYmIChydWxlcy5sdGUgPCB0aGlzICYmIHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgb3IgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdGVdKSA6ICcnSAES7QcKA2d0ZRgFIAEoBkLdB8JI2QcKigEKC2ZpeGVkNjQuZ3RlGnshaGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8IHJ1bGVzLmd0ZT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZV0pIDogJycKxAEKDmZpeGVkNjQuZ3RlX2x0GrEBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndGUgJiYgKHRoaXMgPj0gcnVsZXMubHQgfHwgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBhbmQgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRdKSA6ICcnCswBChhmaXhlZDY0Lmd0ZV9sdF9leGNsdXNpdmUarwFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0IDwgcnVsZXMuZ3RlICYmIChydWxlcy5sdCA8PSB0aGlzICYmIHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgb3IgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRdKSA6ICcnCtQBCg9maXhlZDY0Lmd0ZV9sdGUawAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3RlICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJycK3AEKGWZpeGVkNjQuZ3RlX2x0ZV9leGNsdXNpdmUavgFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndGUgJiYgKHJ1bGVzLmx0ZSA8IHRoaXMgJiYgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBvciBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdGVdKSA6ICcnSAESgQEKAmluGAYgAygGQnXCSHIKcAoKZml4ZWQ2NC5pbhpiISh0aGlzIGluIGdldEZpZWxkKHJ1bGVzLCAnaW4nKSkgPyAndmFsdWUgbXVzdCBiZSBpbiBsaXN0ICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnaW4nKV0pIDogJycSeAoGbm90X2luGAcgAygGQmjCSGUKYwoOZml4ZWQ2NC5ub3RfaW4aUXRoaXMgaW4gcnVsZXMubm90X2luID8gJ3ZhbHVlIG11c3Qgbm90IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbcnVsZXMubm90X2luXSkgOiAnJxItCgdleGFtcGxlGAggAygGQhzCSBkKFwoPZml4ZWQ2NC5leGFtcGxlGgR0cnVlKgkI6AcQgICAgAJCCwoJbGVzc190aGFuQg4KDGdyZWF0ZXJfdGhhbiLAFQoNU0ZpeGVkMzJSdWxlcxKGAQoFY29uc3QYASABKA9Cd8JIdApyCg5zZml4ZWQzMi5jb25zdBpgdGhpcyAhPSBnZXRGaWVsZChydWxlcywgJ2NvbnN0JykgPyAndmFsdWUgbXVzdCBlcXVhbCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2NvbnN0JyldKSA6ICcnEo0BCgJsdBgCIAEoD0J/wkh8CnoKC3NmaXhlZDMyLmx0GmshaGFzKHJ1bGVzLmd0ZSkgJiYgIWhhcyhydWxlcy5ndCkgJiYgdGhpcyA+PSBydWxlcy5sdD8gJ3ZhbHVlIG11c3QgYmUgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmx0XSkgOiAnJ0gAEp8BCgNsdGUYAyABKA9CjwHCSIsBCogBCgxzZml4ZWQzMi5sdGUaeCFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID4gcnVsZXMubHRlPyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMubHRlXSkgOiAnJ0gAEqYHCgJndBgEIAEoD0KXB8JIkwcKfQoLc2ZpeGVkMzIuZ3QabiFoYXMocnVsZXMubHQpICYmICFoYXMocnVsZXMubHRlKSAmJiB0aGlzIDw9IHJ1bGVzLmd0PyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RdKSA6ICcnCrYBCg5zZml4ZWQzMi5ndF9sdBqjAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPj0gcnVsZXMuZ3QgJiYgKHRoaXMgPj0gcnVsZXMubHQgfHwgdGhpcyA8PSBydWxlcy5ndCk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBhbmQgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdF0pIDogJycKvgEKGHNmaXhlZDMyLmd0X2x0X2V4Y2x1c2l2ZRqhAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPCBydWxlcy5ndCAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCsYBCg9zZml4ZWQzMi5ndF9sdGUasgFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3QgJiYgKHRoaXMgPiBydWxlcy5sdGUgfHwgdGhpcyA8PSBydWxlcy5ndCk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBhbmQgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdGVdKSA6ICcnCs4BChlzZml4ZWQzMi5ndF9sdGVfZXhjbHVzaXZlGrABaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlIDwgcnVsZXMuZ3QgJiYgKHJ1bGVzLmx0ZSA8IHRoaXMgJiYgdGhpcyA8PSBydWxlcy5ndCk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBvciBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0ZV0pIDogJydIARLyBwoDZ3RlGAUgASgPQuIHwkjeBwqLAQoMc2ZpeGVkMzIuZ3RlGnshaGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8IHJ1bGVzLmd0ZT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZV0pIDogJycKxQEKD3NmaXhlZDMyLmd0ZV9sdBqxAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPj0gcnVsZXMuZ3RlICYmICh0aGlzID49IHJ1bGVzLmx0IHx8IHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrNAQoZc2ZpeGVkMzIuZ3RlX2x0X2V4Y2x1c2l2ZRqvAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPCBydWxlcy5ndGUgJiYgKHJ1bGVzLmx0IDw9IHRoaXMgJiYgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBvciBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdF0pIDogJycK1QEKEHNmaXhlZDMyLmd0ZV9sdGUawAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3RlICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJycK3QEKGnNmaXhlZDMyLmd0ZV9sdGVfZXhjbHVzaXZlGr4BaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlIDwgcnVsZXMuZ3RlICYmIChydWxlcy5sdGUgPCB0aGlzICYmIHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgb3IgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRlXSkgOiAnJ0gBEoIBCgJpbhgGIAMoD0J2wkhzCnEKC3NmaXhlZDMyLmluGmIhKHRoaXMgaW4gZ2V0RmllbGQocnVsZXMsICdpbicpKSA/ICd2YWx1ZSBtdXN0IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdpbicpXSkgOiAnJxJ5CgZub3RfaW4YByADKA9CacJIZgpkCg9zZml4ZWQzMi5ub3RfaW4aUXRoaXMgaW4gcnVsZXMubm90X2luID8gJ3ZhbHVlIG11c3Qgbm90IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbcnVsZXMubm90X2luXSkgOiAnJxIuCgdleGFtcGxlGAggAygPQh3CSBoKGAoQc2ZpeGVkMzIuZXhhbXBsZRoEdHJ1ZSoJCOgHEICAgIACQgsKCWxlc3NfdGhhbkIOCgxncmVhdGVyX3RoYW4iwBUKDVNGaXhlZDY0UnVsZXMShgEKBWNvbnN0GAEgASgQQnfCSHQKcgoOc2ZpeGVkNjQuY29uc3QaYHRoaXMgIT0gZ2V0RmllbGQocnVsZXMsICdjb25zdCcpID8gJ3ZhbHVlIG11c3QgZXF1YWwgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdjb25zdCcpXSkgOiAnJxKNAQoCbHQYAiABKBBCf8JIfAp6CgtzZml4ZWQ2NC5sdBprIWhhcyhydWxlcy5ndGUpICYmICFoYXMocnVsZXMuZ3QpICYmIHRoaXMgPj0gcnVsZXMubHQ/ICd2YWx1ZSBtdXN0IGJlIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5sdF0pIDogJydIABKfAQoDbHRlGAMgASgQQo8BwkiLAQqIAQoMc2ZpeGVkNjQubHRlGnghaGFzKHJ1bGVzLmd0ZSkgJiYgIWhhcyhydWxlcy5ndCkgJiYgdGhpcyA+IHJ1bGVzLmx0ZT8gJ3ZhbHVlIG11c3QgYmUgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmx0ZV0pIDogJydIABKmBwoCZ3QYBCABKBBClwfCSJMHCn0KC3NmaXhlZDY0Lmd0Gm4haGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8PSBydWxlcy5ndD8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0XSkgOiAnJwq2AQoOc2ZpeGVkNjQuZ3RfbHQaowFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0ID49IHJ1bGVzLmd0ICYmICh0aGlzID49IHJ1bGVzLmx0IHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCr4BChhzZml4ZWQ2NC5ndF9sdF9leGNsdXNpdmUaoQFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0IDwgcnVsZXMuZ3QgJiYgKHJ1bGVzLmx0IDw9IHRoaXMgJiYgdGhpcyA8PSBydWxlcy5ndCk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBvciBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwrGAQoPc2ZpeGVkNjQuZ3RfbHRlGrIBaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlID49IHJ1bGVzLmd0ICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJwrOAQoZc2ZpeGVkNjQuZ3RfbHRlX2V4Y2x1c2l2ZRqwAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ICYmIChydWxlcy5sdGUgPCB0aGlzICYmIHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgb3IgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdGVdKSA6ICcnSAES8gcKA2d0ZRgFIAEoEELiB8JI3gcKiwEKDHNmaXhlZDY0Lmd0ZRp7IWhhcyhydWxlcy5sdCkgJiYgIWhhcyhydWxlcy5sdGUpICYmIHRoaXMgPCBydWxlcy5ndGU/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGVdKSA6ICcnCsUBCg9zZml4ZWQ2NC5ndGVfbHQasQFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0ID49IHJ1bGVzLmd0ZSAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdF0pIDogJycKzQEKGXNmaXhlZDY0Lmd0ZV9sdF9leGNsdXNpdmUarwFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0IDwgcnVsZXMuZ3RlICYmIChydWxlcy5sdCA8PSB0aGlzICYmIHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgb3IgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRdKSA6ICcnCtUBChBzZml4ZWQ2NC5ndGVfbHRlGsABaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlID49IHJ1bGVzLmd0ZSAmJiAodGhpcyA+IHJ1bGVzLmx0ZSB8fCB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIGFuZCBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdGVdKSA6ICcnCt0BChpzZml4ZWQ2NC5ndGVfbHRlX2V4Y2x1c2l2ZRq+AWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJydIARKCAQoCaW4YBiADKBBCdsJIcwpxCgtzZml4ZWQ2NC5pbhpiISh0aGlzIGluIGdldEZpZWxkKHJ1bGVzLCAnaW4nKSkgPyAndmFsdWUgbXVzdCBiZSBpbiBsaXN0ICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnaW4nKV0pIDogJycSeQoGbm90X2luGAcgAygQQmnCSGYKZAoPc2ZpeGVkNjQubm90X2luGlF0aGlzIGluIHJ1bGVzLm5vdF9pbiA/ICd2YWx1ZSBtdXN0IG5vdCBiZSBpbiBsaXN0ICVzJy5mb3JtYXQoW3J1bGVzLm5vdF9pbl0pIDogJycSLgoHZXhhbXBsZRgIIAMoEEIdwkgaChgKEHNmaXhlZDY0LmV4YW1wbGUaBHRydWUqCQjoBxCAgICAAkILCglsZXNzX3RoYW5CDgoMZ3JlYXRlcl90aGFuIscBCglCb29sUnVsZXMSggEKBWNvbnN0GAEgASgIQnPCSHAKbgoKYm9vbC5jb25zdBpgdGhpcyAhPSBnZXRGaWVsZChydWxlcywgJ2NvbnN0JykgPyAndmFsdWUgbXVzdCBlcXVhbCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2NvbnN0JyldKSA6ICcnEioKB2V4YW1wbGUYAiADKAhCGcJIFgoUCgxib29sLmV4YW1wbGUaBHRydWUqCQjoBxCAgICAAiKQNwoLU3RyaW5nUnVsZXMShgEKBWNvbnN0GAEgASgJQnfCSHQKcgoMc3RyaW5nLmNvbnN0GmJ0aGlzICE9IGdldEZpZWxkKHJ1bGVzLCAnY29uc3QnKSA/ICd2YWx1ZSBtdXN0IGVxdWFsIGAlc2AnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdjb25zdCcpXSkgOiAnJxJ+CgNsZW4YEyABKARCccJIbgpsCgpzdHJpbmcubGVuGl51aW50KHRoaXMuc2l6ZSgpKSAhPSBydWxlcy5sZW4gPyAndmFsdWUgbGVuZ3RoIG11c3QgYmUgJXMgY2hhcmFjdGVycycuZm9ybWF0KFtydWxlcy5sZW5dKSA6ICcnEpkBCgdtaW5fbGVuGAIgASgEQocBwkiDAQqAAQoOc3RyaW5nLm1pbl9sZW4abnVpbnQodGhpcy5zaXplKCkpIDwgcnVsZXMubWluX2xlbiA/ICd2YWx1ZSBsZW5ndGggbXVzdCBiZSBhdCBsZWFzdCAlcyBjaGFyYWN0ZXJzJy5mb3JtYXQoW3J1bGVzLm1pbl9sZW5dKSA6ICcnEpcBCgdtYXhfbGVuGAMgASgEQoUBwkiBAQp/Cg5zdHJpbmcubWF4X2xlbhptdWludCh0aGlzLnNpemUoKSkgPiBydWxlcy5tYXhfbGVuID8gJ3ZhbHVlIGxlbmd0aCBtdXN0IGJlIGF0IG1vc3QgJXMgY2hhcmFjdGVycycuZm9ybWF0KFtydWxlcy5tYXhfbGVuXSkgOiAnJxKbAQoJbGVuX2J5dGVzGBQgASgEQocBwkiDAQqAAQoQc3RyaW5nLmxlbl9ieXRlcxpsdWludChieXRlcyh0aGlzKS5zaXplKCkpICE9IHJ1bGVzLmxlbl9ieXRlcyA/ICd2YWx1ZSBsZW5ndGggbXVzdCBiZSAlcyBieXRlcycuZm9ybWF0KFtydWxlcy5sZW5fYnl0ZXNdKSA6ICcnEqMBCgltaW5fYnl0ZXMYBCABKARCjwHCSIsBCogBChBzdHJpbmcubWluX2J5dGVzGnR1aW50KGJ5dGVzKHRoaXMpLnNpemUoKSkgPCBydWxlcy5taW5fYnl0ZXMgPyAndmFsdWUgbGVuZ3RoIG11c3QgYmUgYXQgbGVhc3QgJXMgYnl0ZXMnLmZvcm1hdChbcnVsZXMubWluX2J5dGVzXSkgOiAnJxKiAQoJbWF4X2J5dGVzGAUgASgEQo4BwkiKAQqHAQoQc3RyaW5nLm1heF9ieXRlcxpzdWludChieXRlcyh0aGlzKS5zaXplKCkpID4gcnVsZXMubWF4X2J5dGVzID8gJ3ZhbHVlIGxlbmd0aCBtdXN0IGJlIGF0IG1vc3QgJXMgYnl0ZXMnLmZvcm1hdChbcnVsZXMubWF4X2J5dGVzXSkgOiAnJxKNAQoHcGF0dGVybhgGIAEoCUJ8wkh5CncKDnN0cmluZy5wYXR0ZXJuGmUhdGhpcy5tYXRjaGVzKHJ1bGVzLnBhdHRlcm4pID8gJ3ZhbHVlIGRvZXMgbm90IG1hdGNoIHJlZ2V4IHBhdHRlcm4gYCVzYCcuZm9ybWF0KFtydWxlcy5wYXR0ZXJuXSkgOiAnJxKEAQoGcHJlZml4GAcgASgJQnTCSHEKbwoNc3RyaW5nLnByZWZpeBpeIXRoaXMuc3RhcnRzV2l0aChydWxlcy5wcmVmaXgpID8gJ3ZhbHVlIGRvZXMgbm90IGhhdmUgcHJlZml4IGAlc2AnLmZvcm1hdChbcnVsZXMucHJlZml4XSkgOiAnJxKCAQoGc3VmZml4GAggASgJQnLCSG8KbQoNc3RyaW5nLnN1ZmZpeBpcIXRoaXMuZW5kc1dpdGgocnVsZXMuc3VmZml4KSA/ICd2YWx1ZSBkb2VzIG5vdCBoYXZlIHN1ZmZpeCBgJXNgJy5mb3JtYXQoW3J1bGVzLnN1ZmZpeF0pIDogJycSkAEKCGNvbnRhaW5zGAkgASgJQn7CSHsKeQoPc3RyaW5nLmNvbnRhaW5zGmYhdGhpcy5jb250YWlucyhydWxlcy5jb250YWlucykgPyAndmFsdWUgZG9lcyBub3QgY29udGFpbiBzdWJzdHJpbmcgYCVzYCcuZm9ybWF0KFtydWxlcy5jb250YWluc10pIDogJycSmAEKDG5vdF9jb250YWlucxgXIAEoCUKBAcJIfgp8ChNzdHJpbmcubm90X2NvbnRhaW5zGmV0aGlzLmNvbnRhaW5zKHJ1bGVzLm5vdF9jb250YWlucykgPyAndmFsdWUgY29udGFpbnMgc3Vic3RyaW5nIGAlc2AnLmZvcm1hdChbcnVsZXMubm90X2NvbnRhaW5zXSkgOiAnJxKAAQoCaW4YCiADKAlCdMJIcQpvCglzdHJpbmcuaW4aYiEodGhpcyBpbiBnZXRGaWVsZChydWxlcywgJ2luJykpID8gJ3ZhbHVlIG11c3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2luJyldKSA6ICcnEncKBm5vdF9pbhgLIAMoCUJnwkhkCmIKDXN0cmluZy5ub3RfaW4aUXRoaXMgaW4gcnVsZXMubm90X2luID8gJ3ZhbHVlIG11c3Qgbm90IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbcnVsZXMubm90X2luXSkgOiAnJxLfAQoFZW1haWwYDCABKAhCzQHCSMkBCmEKDHN0cmluZy5lbWFpbBIjdmFsdWUgbXVzdCBiZSBhIHZhbGlkIGVtYWlsIGFkZHJlc3MaLCFydWxlcy5lbWFpbCB8fCB0aGlzID09ICcnIHx8IHRoaXMuaXNFbWFpbCgpCmQKEnN0cmluZy5lbWFpbF9lbXB0eRIydmFsdWUgaXMgZW1wdHksIHdoaWNoIGlzIG5vdCBhIHZhbGlkIGVtYWlsIGFkZHJlc3MaGiFydWxlcy5lbWFpbCB8fCB0aGlzICE9ICcnSAAS5wEKCGhvc3RuYW1lGA0gASgIQtIBwkjOAQplCg9zdHJpbmcuaG9zdG5hbWUSHnZhbHVlIG11c3QgYmUgYSB2YWxpZCBob3N0bmFtZRoyIXJ1bGVzLmhvc3RuYW1lIHx8IHRoaXMgPT0gJycgfHwgdGhpcy5pc0hvc3RuYW1lKCkKZQoVc3RyaW5nLmhvc3RuYW1lX2VtcHR5Ei12YWx1ZSBpcyBlbXB0eSwgd2hpY2ggaXMgbm90IGEgdmFsaWQgaG9zdG5hbWUaHSFydWxlcy5ob3N0bmFtZSB8fCB0aGlzICE9ICcnSAASxwEKAmlwGA4gASgIQrgBwki0AQpVCglzdHJpbmcuaXASIHZhbHVlIG11c3QgYmUgYSB2YWxpZCBJUCBhZGRyZXNzGiYhcnVsZXMuaXAgfHwgdGhpcyA9PSAnJyB8fCB0aGlzLmlzSXAoKQpbCg9zdHJpbmcuaXBfZW1wdHkSL3ZhbHVlIGlzIGVtcHR5LCB3aGljaCBpcyBub3QgYSB2YWxpZCBJUCBhZGRyZXNzGhchcnVsZXMuaXAgfHwgdGhpcyAhPSAnJ0gAEtYBCgRpcHY0GA8gASgIQsUBwkjBAQpcCgtzdHJpbmcuaXB2NBIidmFsdWUgbXVzdCBiZSBhIHZhbGlkIElQdjQgYWRkcmVzcxopIXJ1bGVzLmlwdjQgfHwgdGhpcyA9PSAnJyB8fCB0aGlzLmlzSXAoNCkKYQoRc3RyaW5nLmlwdjRfZW1wdHkSMXZhbHVlIGlzIGVtcHR5LCB3aGljaCBpcyBub3QgYSB2YWxpZCBJUHY0IGFkZHJlc3MaGSFydWxlcy5pcHY0IHx8IHRoaXMgIT0gJydIABLWAQoEaXB2NhgQIAEoCELFAcJIwQEKXAoLc3RyaW5nLmlwdjYSInZhbHVlIG11c3QgYmUgYSB2YWxpZCBJUHY2IGFkZHJlc3MaKSFydWxlcy5pcHY2IHx8IHRoaXMgPT0gJycgfHwgdGhpcy5pc0lwKDYpCmEKEXN0cmluZy5pcHY2X2VtcHR5EjF2YWx1ZSBpcyBlbXB0eSwgd2hpY2ggaXMgbm90IGEgdmFsaWQgSVB2NiBhZGRyZXNzGhkhcnVsZXMuaXB2NiB8fCB0aGlzICE9ICcnSAASvwEKA3VyaRgRIAEoCEKvAcJIqwEKUQoKc3RyaW5nLnVyaRIZdmFsdWUgbXVzdCBiZSBhIHZhbGlkIFVSSRooIXJ1bGVzLnVyaSB8fCB0aGlzID09ICcnIHx8IHRoaXMuaXNVcmkoKQpWChBzdHJpbmcudXJpX2VtcHR5Eih2YWx1ZSBpcyBlbXB0eSwgd2hpY2ggaXMgbm90IGEgdmFsaWQgVVJJGhghcnVsZXMudXJpIHx8IHRoaXMgIT0gJydIABJwCgd1cmlfcmVmGBIgASgIQl3CSFoKWAoOc3RyaW5nLnVyaV9yZWYSI3ZhbHVlIG11c3QgYmUgYSB2YWxpZCBVUkkgUmVmZXJlbmNlGiEhcnVsZXMudXJpX3JlZiB8fCB0aGlzLmlzVXJpUmVmKClIABKQAgoHYWRkcmVzcxgVIAEoCEL8AcJI+AEKgQEKDnN0cmluZy5hZGRyZXNzEi12YWx1ZSBtdXN0IGJlIGEgdmFsaWQgaG9zdG5hbWUsIG9yIGlwIGFkZHJlc3MaQCFydWxlcy5hZGRyZXNzIHx8IHRoaXMgPT0gJycgfHwgdGhpcy5pc0hvc3RuYW1lKCkgfHwgdGhpcy5pc0lwKCkKcgoUc3RyaW5nLmFkZHJlc3NfZW1wdHkSPHZhbHVlIGlzIGVtcHR5LCB3aGljaCBpcyBub3QgYSB2YWxpZCBob3N0bmFtZSwgb3IgaXAgYWRkcmVzcxocIXJ1bGVzLmFkZHJlc3MgfHwgdGhpcyAhPSAnJ0gAEpgCCgR1dWlkGBYgASgIQocCwkiDAgqlAQoLc3RyaW5nLnV1aWQSGnZhbHVlIG11c3QgYmUgYSB2YWxpZCBVVUlEGnohcnVsZXMudXVpZCB8fCB0aGlzID09ICcnIHx8IHRoaXMubWF0Y2hlcygnXlswLTlhLWZBLUZdezh9LVswLTlhLWZBLUZdezR9LVswLTlhLWZBLUZdezR9LVswLTlhLWZBLUZdezR9LVswLTlhLWZBLUZdezEyfSQnKQpZChFzdHJpbmcudXVpZF9lbXB0eRIpdmFsdWUgaXMgZW1wdHksIHdoaWNoIGlzIG5vdCBhIHZhbGlkIFVVSUQaGSFydWxlcy51dWlkIHx8IHRoaXMgIT0gJydIABLwAQoFdHV1aWQYISABKAhC3gHCSNoBCnMKDHN0cmluZy50dXVpZBIidmFsdWUgbXVzdCBiZSBhIHZhbGlkIHRyaW1tZWQgVVVJRBo/IXJ1bGVzLnR1dWlkIHx8IHRoaXMgPT0gJycgfHwgdGhpcy5tYXRjaGVzKCdeWzAtOWEtZkEtRl17MzJ9JCcpCmMKEnN0cmluZy50dXVpZF9lbXB0eRIxdmFsdWUgaXMgZW1wdHksIHdoaWNoIGlzIG5vdCBhIHZhbGlkIHRyaW1tZWQgVVVJRBoaIXJ1bGVzLnR1dWlkIHx8IHRoaXMgIT0gJydIABKWAgoRaXBfd2l0aF9wcmVmaXhsZW4YGiABKAhC+AHCSPQBCngKGHN0cmluZy5pcF93aXRoX3ByZWZpeGxlbhIfdmFsdWUgbXVzdCBiZSBhIHZhbGlkIElQIHByZWZpeBo7IXJ1bGVzLmlwX3dpdGhfcHJlZml4bGVuIHx8IHRoaXMgPT0gJycgfHwgdGhpcy5pc0lwUHJlZml4KCkKeAoec3RyaW5nLmlwX3dpdGhfcHJlZml4bGVuX2VtcHR5Ei52YWx1ZSBpcyBlbXB0eSwgd2hpY2ggaXMgbm90IGEgdmFsaWQgSVAgcHJlZml4GiYhcnVsZXMuaXBfd2l0aF9wcmVmaXhsZW4gfHwgdGhpcyAhPSAnJ0gAEs8CChNpcHY0X3dpdGhfcHJlZml4bGVuGBsgASgIQq8CwkirAgqTAQoac3RyaW5nLmlwdjRfd2l0aF9wcmVmaXhsZW4SNXZhbHVlIG11c3QgYmUgYSB2YWxpZCBJUHY0IGFkZHJlc3Mgd2l0aCBwcmVmaXggbGVuZ3RoGj4hcnVsZXMuaXB2NF93aXRoX3ByZWZpeGxlbiB8fCB0aGlzID09ICcnIHx8IHRoaXMuaXNJcFByZWZpeCg0KQqSAQogc3RyaW5nLmlwdjRfd2l0aF9wcmVmaXhsZW5fZW1wdHkSRHZhbHVlIGlzIGVtcHR5LCB3aGljaCBpcyBub3QgYSB2YWxpZCBJUHY0IGFkZHJlc3Mgd2l0aCBwcmVmaXggbGVuZ3RoGighcnVsZXMuaXB2NF93aXRoX3ByZWZpeGxlbiB8fCB0aGlzICE9ICcnSAASzwIKE2lwdjZfd2l0aF9wcmVmaXhsZW4YHCABKAhCrwLCSKsCCpMBChpzdHJpbmcuaXB2Nl93aXRoX3ByZWZpeGxlbhI1dmFsdWUgbXVzdCBiZSBhIHZhbGlkIElQdjYgYWRkcmVzcyB3aXRoIHByZWZpeCBsZW5ndGgaPiFydWxlcy5pcHY2X3dpdGhfcHJlZml4bGVuIHx8IHRoaXMgPT0gJycgfHwgdGhpcy5pc0lwUHJlZml4KDYpCpIBCiBzdHJpbmcuaXB2Nl93aXRoX3ByZWZpeGxlbl9lbXB0eRJEdmFsdWUgaXMgZW1wdHksIHdoaWNoIGlzIG5vdCBhIHZhbGlkIElQdjYgYWRkcmVzcyB3aXRoIHByZWZpeCBsZW5ndGgaKCFydWxlcy5pcHY2X3dpdGhfcHJlZml4bGVuIHx8IHRoaXMgIT0gJydIABLyAQoJaXBfcHJlZml4GB0gASgIQtwBwkjYAQpsChBzdHJpbmcuaXBfcHJlZml4Eh92YWx1ZSBtdXN0IGJlIGEgdmFsaWQgSVAgcHJlZml4GjchcnVsZXMuaXBfcHJlZml4IHx8IHRoaXMgPT0gJycgfHwgdGhpcy5pc0lwUHJlZml4KHRydWUpCmgKFnN0cmluZy5pcF9wcmVmaXhfZW1wdHkSLnZhbHVlIGlzIGVtcHR5LCB3aGljaCBpcyBub3QgYSB2YWxpZCBJUCBwcmVmaXgaHiFydWxlcy5pcF9wcmVmaXggfHwgdGhpcyAhPSAnJ0gAEoMCCgtpcHY0X3ByZWZpeBgeIAEoCELrAcJI5wEKdQoSc3RyaW5nLmlwdjRfcHJlZml4EiF2YWx1ZSBtdXN0IGJlIGEgdmFsaWQgSVB2NCBwcmVmaXgaPCFydWxlcy5pcHY0X3ByZWZpeCB8fCB0aGlzID09ICcnIHx8IHRoaXMuaXNJcFByZWZpeCg0LCB0cnVlKQpuChhzdHJpbmcuaXB2NF9wcmVmaXhfZW1wdHkSMHZhbHVlIGlzIGVtcHR5LCB3aGljaCBpcyBub3QgYSB2YWxpZCBJUHY0IHByZWZpeBogIXJ1bGVzLmlwdjRfcHJlZml4IHx8IHRoaXMgIT0gJydIABKDAgoLaXB2Nl9wcmVmaXgYHyABKAhC6wHCSOcBCnUKEnN0cmluZy5pcHY2X3ByZWZpeBIhdmFsdWUgbXVzdCBiZSBhIHZhbGlkIElQdjYgcHJlZml4GjwhcnVsZXMuaXB2Nl9wcmVmaXggfHwgdGhpcyA9PSAnJyB8fCB0aGlzLmlzSXBQcmVmaXgoNiwgdHJ1ZSkKbgoYc3RyaW5nLmlwdjZfcHJlZml4X2VtcHR5EjB2YWx1ZSBpcyBlbXB0eSwgd2hpY2ggaXMgbm90IGEgdmFsaWQgSVB2NiBwcmVmaXgaICFydWxlcy5pcHY2X3ByZWZpeCB8fCB0aGlzICE9ICcnSAAStQIKDWhvc3RfYW5kX3BvcnQYICABKAhCmwLCSJcCCpkBChRzdHJpbmcuaG9zdF9hbmRfcG9ydBJBdmFsdWUgbXVzdCBiZSBhIHZhbGlkIGhvc3QgKGhvc3RuYW1lIG9yIElQIGFkZHJlc3MpIGFuZCBwb3J0IHBhaXIaPiFydWxlcy5ob3N0X2FuZF9wb3J0IHx8IHRoaXMgPT0gJycgfHwgdGhpcy5pc0hvc3RBbmRQb3J0KHRydWUpCnkKGnN0cmluZy5ob3N0X2FuZF9wb3J0X2VtcHR5Ejd2YWx1ZSBpcyBlbXB0eSwgd2hpY2ggaXMgbm90IGEgdmFsaWQgaG9zdCBhbmQgcG9ydCBwYWlyGiIhcnVsZXMuaG9zdF9hbmRfcG9ydCB8fCB0aGlzICE9ICcnSAASqAUKEHdlbGxfa25vd25fcmVnZXgYGCABKA4yGC5idWYudmFsaWRhdGUuS25vd25SZWdleELxBMJI7QQK8AEKI3N0cmluZy53ZWxsX2tub3duX3JlZ2V4LmhlYWRlcl9uYW1lEiZ2YWx1ZSBtdXN0IGJlIGEgdmFsaWQgSFRUUCBoZWFkZXIgbmFtZRqgAXJ1bGVzLndlbGxfa25vd25fcmVnZXggIT0gMSB8fCB0aGlzID09ICcnIHx8IHRoaXMubWF0Y2hlcyghaGFzKHJ1bGVzLnN0cmljdCkgfHwgcnVsZXMuc3RyaWN0ID8nXjo/WzAtOWEtekEtWiEjJCUmXCcqKy0uXl98flx4NjBdKyQnIDonXlteXHUwMDAwXHUwMDBBXHUwMDBEXSskJykKjQEKKXN0cmluZy53ZWxsX2tub3duX3JlZ2V4LmhlYWRlcl9uYW1lX2VtcHR5EjV2YWx1ZSBpcyBlbXB0eSwgd2hpY2ggaXMgbm90IGEgdmFsaWQgSFRUUCBoZWFkZXIgbmFtZRopcnVsZXMud2VsbF9rbm93bl9yZWdleCAhPSAxIHx8IHRoaXMgIT0gJycK5wEKJHN0cmluZy53ZWxsX2tub3duX3JlZ2V4LmhlYWRlcl92YWx1ZRIndmFsdWUgbXVzdCBiZSBhIHZhbGlkIEhUVFAgaGVhZGVyIHZhbHVlGpUBcnVsZXMud2VsbF9rbm93bl9yZWdleCAhPSAyIHx8IHRoaXMubWF0Y2hlcyghaGFzKHJ1bGVzLnN0cmljdCkgfHwgcnVsZXMuc3RyaWN0ID8nXlteXHUwMDAwLVx1MDAwOFx1MDAwQS1cdTAwMUZcdTAwN0ZdKiQnIDonXlteXHUwMDAwXHUwMDBBXHUwMDBEXSokJylIABIOCgZzdHJpY3QYGSABKAgSLAoHZXhhbXBsZRgiIAMoCUIbwkgYChYKDnN0cmluZy5leGFtcGxlGgR0cnVlKgkI6AcQgICAgAJCDAoKd2VsbF9rbm93biLqEAoKQnl0ZXNSdWxlcxKAAQoFY29uc3QYASABKAxCccJIbgpsCgtieXRlcy5jb25zdBpddGhpcyAhPSBnZXRGaWVsZChydWxlcywgJ2NvbnN0JykgPyAndmFsdWUgbXVzdCBiZSAleCcuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2NvbnN0JyldKSA6ICcnEngKA2xlbhgNIAEoBEJrwkhoCmYKCWJ5dGVzLmxlbhpZdWludCh0aGlzLnNpemUoKSkgIT0gcnVsZXMubGVuID8gJ3ZhbHVlIGxlbmd0aCBtdXN0IGJlICVzIGJ5dGVzJy5mb3JtYXQoW3J1bGVzLmxlbl0pIDogJycSkAEKB21pbl9sZW4YAiABKARCf8JIfAp6Cg1ieXRlcy5taW5fbGVuGml1aW50KHRoaXMuc2l6ZSgpKSA8IHJ1bGVzLm1pbl9sZW4gPyAndmFsdWUgbGVuZ3RoIG11c3QgYmUgYXQgbGVhc3QgJXMgYnl0ZXMnLmZvcm1hdChbcnVsZXMubWluX2xlbl0pIDogJycSiAEKB21heF9sZW4YAyABKARCd8JIdApyCg1ieXRlcy5tYXhfbGVuGmF1aW50KHRoaXMuc2l6ZSgpKSA+IHJ1bGVzLm1heF9sZW4gPyAndmFsdWUgbXVzdCBiZSBhdCBtb3N0ICVzIGJ5dGVzJy5mb3JtYXQoW3J1bGVzLm1heF9sZW5dKSA6ICcnEpABCgdwYXR0ZXJuGAQgASgJQn/CSHwKegoNYnl0ZXMucGF0dGVybhppIXN0cmluZyh0aGlzKS5tYXRjaGVzKHJ1bGVzLnBhdHRlcm4pID8gJ3ZhbHVlIG11c3QgbWF0Y2ggcmVnZXggcGF0dGVybiBgJXNgJy5mb3JtYXQoW3J1bGVzLnBhdHRlcm5dKSA6ICcnEoEBCgZwcmVmaXgYBSABKAxCccJIbgpsCgxieXRlcy5wcmVmaXgaXCF0aGlzLnN0YXJ0c1dpdGgocnVsZXMucHJlZml4KSA/ICd2YWx1ZSBkb2VzIG5vdCBoYXZlIHByZWZpeCAleCcuZm9ybWF0KFtydWxlcy5wcmVmaXhdKSA6ICcnEn8KBnN1ZmZpeBgGIAEoDEJvwkhsCmoKDGJ5dGVzLnN1ZmZpeBpaIXRoaXMuZW5kc1dpdGgocnVsZXMuc3VmZml4KSA/ICd2YWx1ZSBkb2VzIG5vdCBoYXZlIHN1ZmZpeCAleCcuZm9ybWF0KFtydWxlcy5zdWZmaXhdKSA6ICcnEoMBCghjb250YWlucxgHIAEoDEJxwkhuCmwKDmJ5dGVzLmNvbnRhaW5zGlohdGhpcy5jb250YWlucyhydWxlcy5jb250YWlucykgPyAndmFsdWUgZG9lcyBub3QgY29udGFpbiAleCcuZm9ybWF0KFtydWxlcy5jb250YWluc10pIDogJycSpwEKAmluGAggAygMQpoBwkiWAQqTAQoIYnl0ZXMuaW4ahgFnZXRGaWVsZChydWxlcywgJ2luJykuc2l6ZSgpID4gMCAmJiAhKHRoaXMgaW4gZ2V0RmllbGQocnVsZXMsICdpbicpKSA/ICd2YWx1ZSBtdXN0IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdpbicpXSkgOiAnJxJ2CgZub3RfaW4YCSADKAxCZsJIYwphCgxieXRlcy5ub3RfaW4aUXRoaXMgaW4gcnVsZXMubm90X2luID8gJ3ZhbHVlIG11c3Qgbm90IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbcnVsZXMubm90X2luXSkgOiAnJxLrAQoCaXAYCiABKAhC3AHCSNgBCnQKCGJ5dGVzLmlwEiB2YWx1ZSBtdXN0IGJlIGEgdmFsaWQgSVAgYWRkcmVzcxpGIXJ1bGVzLmlwIHx8IHRoaXMuc2l6ZSgpID09IDAgfHwgdGhpcy5zaXplKCkgPT0gNCB8fCB0aGlzLnNpemUoKSA9PSAxNgpgCg5ieXRlcy5pcF9lbXB0eRIvdmFsdWUgaXMgZW1wdHksIHdoaWNoIGlzIG5vdCBhIHZhbGlkIElQIGFkZHJlc3MaHSFydWxlcy5pcCB8fCB0aGlzLnNpemUoKSAhPSAwSAAS5AEKBGlwdjQYCyABKAhC0wHCSM8BCmUKCmJ5dGVzLmlwdjQSInZhbHVlIG11c3QgYmUgYSB2YWxpZCBJUHY0IGFkZHJlc3MaMyFydWxlcy5pcHY0IHx8IHRoaXMuc2l6ZSgpID09IDAgfHwgdGhpcy5zaXplKCkgPT0gNApmChBieXRlcy5pcHY0X2VtcHR5EjF2YWx1ZSBpcyBlbXB0eSwgd2hpY2ggaXMgbm90IGEgdmFsaWQgSVB2NCBhZGRyZXNzGh8hcnVsZXMuaXB2NCB8fCB0aGlzLnNpemUoKSAhPSAwSAAS5QEKBGlwdjYYDCABKAhC1AHCSNABCmYKCmJ5dGVzLmlwdjYSInZhbHVlIG11c3QgYmUgYSB2YWxpZCBJUHY2IGFkZHJlc3MaNCFydWxlcy5pcHY2IHx8IHRoaXMuc2l6ZSgpID09IDAgfHwgdGhpcy5zaXplKCkgPT0gMTYKZgoQYnl0ZXMuaXB2Nl9lbXB0eRIxdmFsdWUgaXMgZW1wdHksIHdoaWNoIGlzIG5vdCBhIHZhbGlkIElQdjYgYWRkcmVzcxofIXJ1bGVzLmlwdjYgfHwgdGhpcy5zaXplKCkgIT0gMEgAEisKB2V4YW1wbGUYDiADKAxCGsJIFwoVCg1ieXRlcy5leGFtcGxlGgR0cnVlKgkI6AcQgICAgAJCDAoKd2VsbF9rbm93biLUAwoJRW51bVJ1bGVzEoIBCgVjb25zdBgBIAEoBUJzwkhwCm4KCmVudW0uY29uc3QaYHRoaXMgIT0gZ2V0RmllbGQocnVsZXMsICdjb25zdCcpID8gJ3ZhbHVlIG11c3QgZXF1YWwgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdjb25zdCcpXSkgOiAnJxIUCgxkZWZpbmVkX29ubHkYAiABKAgSfgoCaW4YAyADKAVCcsJIbwptCgdlbnVtLmluGmIhKHRoaXMgaW4gZ2V0RmllbGQocnVsZXMsICdpbicpKSA/ICd2YWx1ZSBtdXN0IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdpbicpXSkgOiAnJxJ1CgZub3RfaW4YBCADKAVCZcJIYgpgCgtlbnVtLm5vdF9pbhpRdGhpcyBpbiBydWxlcy5ub3RfaW4gPyAndmFsdWUgbXVzdCBub3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtydWxlcy5ub3RfaW5dKSA6ICcnEioKB2V4YW1wbGUYBSADKAVCGcJIFgoUCgxlbnVtLmV4YW1wbGUaBHRydWUqCQjoBxCAgICAAiL7AwoNUmVwZWF0ZWRSdWxlcxKeAQoJbWluX2l0ZW1zGAEgASgEQooBwkiGAQqDAQoScmVwZWF0ZWQubWluX2l0ZW1zGm11aW50KHRoaXMuc2l6ZSgpKSA8IHJ1bGVzLm1pbl9pdGVtcyA/ICd2YWx1ZSBtdXN0IGNvbnRhaW4gYXQgbGVhc3QgJWQgaXRlbShzKScuZm9ybWF0KFtydWxlcy5taW5faXRlbXNdKSA6ICcnEqIBCgltYXhfaXRlbXMYAiABKARCjgHCSIoBCocBChJyZXBlYXRlZC5tYXhfaXRlbXMacXVpbnQodGhpcy5zaXplKCkpID4gcnVsZXMubWF4X2l0ZW1zID8gJ3ZhbHVlIG11c3QgY29udGFpbiBubyBtb3JlIHRoYW4gJXMgaXRlbShzKScuZm9ybWF0KFtydWxlcy5tYXhfaXRlbXNdKSA6ICcnEnAKBnVuaXF1ZRgDIAEoCEJgwkhdClsKD3JlcGVhdGVkLnVuaXF1ZRIocmVwZWF0ZWQgdmFsdWUgbXVzdCBjb250YWluIHVuaXF1ZSBpdGVtcxoeIXJ1bGVzLnVuaXF1ZSB8fCB0aGlzLnVuaXF1ZSgpEicKBWl0ZW1zGAQgASgLMhguYnVmLnZhbGlkYXRlLkZpZWxkUnVsZXMqCQjoBxCAgICAAiKKAwoITWFwUnVsZXMSjwEKCW1pbl9wYWlycxgBIAEoBEJ8wkh5CncKDW1hcC5taW5fcGFpcnMaZnVpbnQodGhpcy5zaXplKCkpIDwgcnVsZXMubWluX3BhaXJzID8gJ21hcCBtdXN0IGJlIGF0IGxlYXN0ICVkIGVudHJpZXMnLmZvcm1hdChbcnVsZXMubWluX3BhaXJzXSkgOiAnJxKOAQoJbWF4X3BhaXJzGAIgASgEQnvCSHgKdgoNbWFwLm1heF9wYWlycxpldWludCh0aGlzLnNpemUoKSkgPiBydWxlcy5tYXhfcGFpcnMgPyAnbWFwIG11c3QgYmUgYXQgbW9zdCAlZCBlbnRyaWVzJy5mb3JtYXQoW3J1bGVzLm1heF9wYWlyc10pIDogJycSJgoEa2V5cxgEIAEoCzIYLmJ1Zi52YWxpZGF0ZS5GaWVsZFJ1bGVzEigKBnZhbHVlcxgFIAEoCzIYLmJ1Zi52YWxpZGF0ZS5GaWVsZFJ1bGVzKgkI6AcQgICAgAIiJgoIQW55UnVsZXMSCgoCaW4YAiADKAkSDgoGbm90X2luGAMgAygJIpkXCg1EdXJhdGlvblJ1bGVzEqEBCgVjb25zdBgCIAEoCzIZLmdvb2dsZS5wcm90b2J1Zi5EdXJhdGlvbkJ3wkh0CnIKDmR1cmF0aW9uLmNvbnN0GmB0aGlzICE9IGdldEZpZWxkKHJ1bGVzLCAnY29uc3QnKSA/ICd2YWx1ZSBtdXN0IGVxdWFsICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnY29uc3QnKV0pIDogJycSqAEKAmx0GAMgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uQn/CSHwKegoLZHVyYXRpb24ubHQaayFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID49IHJ1bGVzLmx0PyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMubHRdKSA6ICcnSAASugEKA2x0ZRgEIAEoCzIZLmdvb2dsZS5wcm90b2J1Zi5EdXJhdGlvbkKPAcJIiwEKiAEKDGR1cmF0aW9uLmx0ZRp4IWhhcyhydWxlcy5ndGUpICYmICFoYXMocnVsZXMuZ3QpICYmIHRoaXMgPiBydWxlcy5sdGU/ICd2YWx1ZSBtdXN0IGJlIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5sdGVdKSA6ICcnSAASwQcKAmd0GAUgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uQpcHwkiTBwp9CgtkdXJhdGlvbi5ndBpuIWhhcyhydWxlcy5sdCkgJiYgIWhhcyhydWxlcy5sdGUpICYmIHRoaXMgPD0gcnVsZXMuZ3Q/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndF0pIDogJycKtgEKDmR1cmF0aW9uLmd0X2x0GqMBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndCAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwq+AQoYZHVyYXRpb24uZ3RfbHRfZXhjbHVzaXZlGqEBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ICYmIChydWxlcy5sdCA8PSB0aGlzICYmIHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgb3IgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdF0pIDogJycKxgEKD2R1cmF0aW9uLmd0X2x0ZRqyAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA+PSBydWxlcy5ndCAmJiAodGhpcyA+IHJ1bGVzLmx0ZSB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0ZV0pIDogJycKzgEKGWR1cmF0aW9uLmd0X2x0ZV9leGNsdXNpdmUasAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndCAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJ0gBEo0ICgNndGUYBiABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb25C4gfCSN4HCosBCgxkdXJhdGlvbi5ndGUaeyFoYXMocnVsZXMubHQpICYmICFoYXMocnVsZXMubHRlKSAmJiB0aGlzIDwgcnVsZXMuZ3RlPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlXSkgOiAnJwrFAQoPZHVyYXRpb24uZ3RlX2x0GrEBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndGUgJiYgKHRoaXMgPj0gcnVsZXMubHQgfHwgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBhbmQgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRdKSA6ICcnCs0BChlkdXJhdGlvbi5ndGVfbHRfZXhjbHVzaXZlGq8BaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrVAQoQZHVyYXRpb24uZ3RlX2x0ZRrAAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA+PSBydWxlcy5ndGUgJiYgKHRoaXMgPiBydWxlcy5sdGUgfHwgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBhbmQgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRlXSkgOiAnJwrdAQoaZHVyYXRpb24uZ3RlX2x0ZV9leGNsdXNpdmUavgFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndGUgJiYgKHJ1bGVzLmx0ZSA8IHRoaXMgJiYgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBvciBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdGVdKSA6ICcnSAESnQEKAmluGAcgAygLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uQnbCSHMKcQoLZHVyYXRpb24uaW4aYiEodGhpcyBpbiBnZXRGaWVsZChydWxlcywgJ2luJykpID8gJ3ZhbHVlIG11c3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2luJyldKSA6ICcnEpQBCgZub3RfaW4YCCADKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb25CacJIZgpkCg9kdXJhdGlvbi5ub3RfaW4aUXRoaXMgaW4gcnVsZXMubm90X2luID8gJ3ZhbHVlIG11c3Qgbm90IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbcnVsZXMubm90X2luXSkgOiAnJxJJCgdleGFtcGxlGAkgAygLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uQh3CSBoKGAoQZHVyYXRpb24uZXhhbXBsZRoEdHJ1ZSoJCOgHEICAgIACQgsKCWxlc3NfdGhhbkIOCgxncmVhdGVyX3RoYW4ikhgKDlRpbWVzdGFtcFJ1bGVzEqMBCgVjb25zdBgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCeMJIdQpzCg90aW1lc3RhbXAuY29uc3QaYHRoaXMgIT0gZ2V0RmllbGQocnVsZXMsICdjb25zdCcpID8gJ3ZhbHVlIG11c3QgZXF1YWwgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdjb25zdCcpXSkgOiAnJxKrAQoCbHQYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQoABwkh9CnsKDHRpbWVzdGFtcC5sdBprIWhhcyhydWxlcy5ndGUpICYmICFoYXMocnVsZXMuZ3QpICYmIHRoaXMgPj0gcnVsZXMubHQ/ICd2YWx1ZSBtdXN0IGJlIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5sdF0pIDogJydIABK8AQoDbHRlGAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEKQAcJIjAEKiQEKDXRpbWVzdGFtcC5sdGUaeCFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID4gcnVsZXMubHRlPyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMubHRlXSkgOiAnJ0gAEmwKBmx0X25vdxgHIAEoCEJawkhXClUKEHRpbWVzdGFtcC5sdF9ub3caQShydWxlcy5sdF9ub3cgJiYgdGhpcyA+IG5vdykgPyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gbm93JyA6ICcnSAASxwcKAmd0GAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEKcB8JImAcKfgoMdGltZXN0YW1wLmd0Gm4haGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8PSBydWxlcy5ndD8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0XSkgOiAnJwq3AQoPdGltZXN0YW1wLmd0X2x0GqMBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndCAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwq/AQoZdGltZXN0YW1wLmd0X2x0X2V4Y2x1c2l2ZRqhAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPCBydWxlcy5ndCAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCscBChB0aW1lc3RhbXAuZ3RfbHRlGrIBaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlID49IHJ1bGVzLmd0ICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJwrPAQoadGltZXN0YW1wLmd0X2x0ZV9leGNsdXNpdmUasAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndCAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJ0gBEpMICgNndGUYBiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQucHwkjjBwqMAQoNdGltZXN0YW1wLmd0ZRp7IWhhcyhydWxlcy5sdCkgJiYgIWhhcyhydWxlcy5sdGUpICYmIHRoaXMgPCBydWxlcy5ndGU/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGVdKSA6ICcnCsYBChB0aW1lc3RhbXAuZ3RlX2x0GrEBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndGUgJiYgKHRoaXMgPj0gcnVsZXMubHQgfHwgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBhbmQgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRdKSA6ICcnCs4BChp0aW1lc3RhbXAuZ3RlX2x0X2V4Y2x1c2l2ZRqvAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPCBydWxlcy5ndGUgJiYgKHJ1bGVzLmx0IDw9IHRoaXMgJiYgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBvciBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdF0pIDogJycK1gEKEXRpbWVzdGFtcC5ndGVfbHRlGsABaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlID49IHJ1bGVzLmd0ZSAmJiAodGhpcyA+IHJ1bGVzLmx0ZSB8fCB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIGFuZCBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdGVdKSA6ICcnCt4BCht0aW1lc3RhbXAuZ3RlX2x0ZV9leGNsdXNpdmUavgFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndGUgJiYgKHJ1bGVzLmx0ZSA8IHRoaXMgJiYgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBvciBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdGVdKSA6ICcnSAESbwoGZ3Rfbm93GAggASgIQl3CSFoKWAoQdGltZXN0YW1wLmd0X25vdxpEKHJ1bGVzLmd0X25vdyAmJiB0aGlzIDwgbm93KSA/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBub3cnIDogJydIARK4AQoGd2l0aGluGAkgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uQowBwkiIAQqFAQoQdGltZXN0YW1wLndpdGhpbhpxdGhpcyA8IG5vdy1ydWxlcy53aXRoaW4gfHwgdGhpcyA+IG5vdytydWxlcy53aXRoaW4gPyAndmFsdWUgbXVzdCBiZSB3aXRoaW4gJXMgb2Ygbm93Jy5mb3JtYXQoW3J1bGVzLndpdGhpbl0pIDogJycSSwoHZXhhbXBsZRgKIAMoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCHsJIGwoZChF0aW1lc3RhbXAuZXhhbXBsZRoEdHJ1ZSoJCOgHEICAgIACQgsKCWxlc3NfdGhhbkIOCgxncmVhdGVyX3RoYW4iOQoKVmlvbGF0aW9ucxIrCgp2aW9sYXRpb25zGAEgAygLMhcuYnVmLnZhbGlkYXRlLlZpb2xhdGlvbiKfAQoJVmlvbGF0aW9uEiYKBWZpZWxkGAUgASgLMhcuYnVmLnZhbGlkYXRlLkZpZWxkUGF0aBIlCgRydWxlGAYgASgLMhcuYnVmLnZhbGlkYXRlLkZpZWxkUGF0aBIPCgdydWxlX2lkGAIgASgJEg8KB21lc3NhZ2UYAyABKAkSDwoHZm9yX2tleRgEIAEoCEoECAEQAlIKZmllbGRfcGF0aCI9CglGaWVsZFBhdGgSMAoIZWxlbWVudHMYASADKAsyHi5idWYudmFsaWRhdGUuRmllbGRQYXRoRWxlbWVudCLpAgoQRmllbGRQYXRoRWxlbWVudBIUCgxmaWVsZF9udW1iZXIYASABKAUSEgoKZmllbGRfbmFtZRgCIAEoCRI+CgpmaWVsZF90eXBlGAMgASgOMiouZ29vZ2xlLnByb3RvYnVmLkZpZWxkRGVzY3JpcHRvclByb3RvLlR5cGUSPAoIa2V5X3R5cGUYBCABKA4yKi5nb29nbGUucHJvdG9idWYuRmllbGREZXNjcmlwdG9yUHJvdG8uVHlwZRI+Cgp2YWx1ZV90eXBlGAUgASgOMiouZ29vZ2xlLnByb3RvYnVmLkZpZWxkRGVzY3JpcHRvclByb3RvLlR5cGUSDwoFaW5kZXgYBiABKARIABISCghib29sX2tleRgHIAEoCEgAEhEKB2ludF9rZXkYCCABKANIABISCgh1aW50X2tleRgJIAEoBEgAEhQKCnN0cmluZ19rZXkYCiABKAlIAEILCglzdWJzY3JpcHQqhwEKBklnbm9yZRIWChJJR05PUkVfVU5TUEVDSUZJRUQQABIZChVJR05PUkVfSUZfVU5QT1BVTEFURUQQARIbChdJR05PUkVfSUZfREVGQVVMVF9WQUxVRRACEhEKDUlHTk9SRV9BTFdBWVMQAyoaSUdOT1JFX0VNUFRZSUdOT1JFX0RFRkFVTFQqbgoKS25vd25SZWdleBIbChdLTk9XTl9SRUdFWF9VTlNQRUNJRklFRBAAEiAKHEtOT1dOX1JFR0VYX0hUVFBfSEVBREVSX05BTUUQARIhCh1LTk9XTl9SRUdFWF9IVFRQX0hFQURFUl9WQUxVRRACOlYKB21lc3NhZ2USHy5nb29nbGUucHJvdG9idWYuTWVzc2FnZU9wdGlvbnMYhwkgASgLMhouYnVmLnZhbGlkYXRlLk1lc3NhZ2VSdWxlc1IHbWVzc2FnZTpOCgVvbmVvZhIdLmdvb2dsZS5wcm90b2J1Zi5PbmVvZk9wdGlvbnMYhwkgASgLMhguYnVmLnZhbGlkYXRlLk9uZW9mUnVsZXNSBW9uZW9mOk4KBWZpZWxkEh0uZ29vZ2xlLnByb3RvYnVmLkZpZWxkT3B0aW9ucxiHCSABKAsyGC5idWYudmFsaWRhdGUuRmllbGRSdWxlc1IFZmllbGQ6XQoKcHJlZGVmaW5lZBIdLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE9wdGlvbnMYiAkgASgLMh0uYnVmLnZhbGlkYXRlLlByZWRlZmluZWRSdWxlc1IKcHJlZGVmaW5lZEJuChJidWlsZC5idWYudmFsaWRhdGVCDVZhbGlkYXRlUHJvdG9QAVpHYnVmLmJ1aWxkL2dlbi9nby9idWZidWlsZC9wcm90b3ZhbGlkYXRlL3Byb3RvY29sYnVmZmVycy9nby9idWYvdmFsaWRhdGU", + [file_google_protobuf_descriptor, file_google_protobuf_duration, file_google_protobuf_timestamp], + ); + +/** + * `Rule` represents a validation rule written in the Common Expression + * Language (CEL) syntax. Each Rule includes a unique identifier, an + * optional error message, and the CEL expression to evaluate. For more + * information on CEL, [see our documentation](https://github.com/bufbuild/protovalidate/blob/main/docs/cel.md). + * + * ```proto + * message Foo { + * option (buf.validate.message).cel = { + * id: "foo.bar" + * message: "bar must be greater than 0" + * expression: "this.bar > 0" + * }; + * int32 bar = 1; + * } + * ``` + * + * @generated from message buf.validate.Rule + */ +export type Rule = Message<"buf.validate.Rule"> & { + /** + * `id` is a string that serves as a machine-readable name for this Rule. + * It should be unique within its scope, which could be either a message or a field. + * + * @generated from field: optional string id = 1; + */ + id: string; + + /** + * `message` is an optional field that provides a human-readable error message + * for this Rule when the CEL expression evaluates to false. If a + * non-empty message is provided, any strings resulting from the CEL + * expression evaluation are ignored. + * + * @generated from field: optional string message = 2; + */ + message: string; + + /** + * `expression` is the actual CEL expression that will be evaluated for + * validation. This string must resolve to either a boolean or a string + * value. If the expression evaluates to false or a non-empty string, the + * validation is considered failed, and the message is rejected. + * + * @generated from field: optional string expression = 3; + */ + expression: string; +}; + +/** + * Describes the message buf.validate.Rule. + * Use `create(RuleSchema)` to create a new message. + */ +export const RuleSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 0); + +/** + * MessageRules represents validation rules that are applied to the entire message. + * It includes disabling options and a list of Rule messages representing Common Expression Language (CEL) validation rules. + * + * @generated from message buf.validate.MessageRules + */ +export type MessageRules = Message<"buf.validate.MessageRules"> & { + /** + * `disabled` is a boolean flag that, when set to true, nullifies any validation rules for this message. + * This includes any fields within the message that would otherwise support validation. + * + * ```proto + * message MyMessage { + * // validation will be bypassed for this message + * option (buf.validate.message).disabled = true; + * } + * ``` + * + * @generated from field: optional bool disabled = 1; + */ + disabled: boolean; + + /** + * `cel` is a repeated field of type Rule. Each Rule specifies a validation rule to be applied to this message. + * These rules are written in Common Expression Language (CEL) syntax. For more information on + * CEL, [see our documentation](https://github.com/bufbuild/protovalidate/blob/main/docs/cel.md). + * + * + * ```proto + * message MyMessage { + * // The field `foo` must be greater than 42. + * option (buf.validate.message).cel = { + * id: "my_message.value", + * message: "value must be greater than 42", + * expression: "this.foo > 42", + * }; + * optional int32 foo = 1; + * } + * ``` + * + * @generated from field: repeated buf.validate.Rule cel = 3; + */ + cel: Rule[]; +}; + +/** + * Describes the message buf.validate.MessageRules. + * Use `create(MessageRulesSchema)` to create a new message. + */ +export const MessageRulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 1); + +/** + * The `OneofRules` message type enables you to manage rules for + * oneof fields in your protobuf messages. + * + * @generated from message buf.validate.OneofRules + */ +export type OneofRules = Message<"buf.validate.OneofRules"> & { + /** + * If `required` is true, exactly one field of the oneof must be present. A + * validation error is returned if no fields in the oneof are present. The + * field itself may still be a default value; further rules + * should be placed on the fields themselves to ensure they are valid values, + * such as `min_len` or `gt`. + * + * ```proto + * message MyMessage { + * oneof value { + * // Either `a` or `b` must be set. If `a` is set, it must also be + * // non-empty; whereas if `b` is set, it can still be an empty string. + * option (buf.validate.oneof).required = true; + * string a = 1 [(buf.validate.field).string.min_len = 1]; + * string b = 2; + * } + * } + * ``` + * + * @generated from field: optional bool required = 1; + */ + required: boolean; +}; + +/** + * Describes the message buf.validate.OneofRules. + * Use `create(OneofRulesSchema)` to create a new message. + */ +export const OneofRulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 2); + +/** + * FieldRules encapsulates the rules for each type of field. Depending on + * the field, the correct set should be used to ensure proper validations. + * + * @generated from message buf.validate.FieldRules + */ +export type FieldRules = Message<"buf.validate.FieldRules"> & { + /** + * `cel` is a repeated field used to represent a textual expression + * in the Common Expression Language (CEL) syntax. For more information on + * CEL, [see our documentation](https://github.com/bufbuild/protovalidate/blob/main/docs/cel.md). + * + * ```proto + * message MyMessage { + * // The field `value` must be greater than 42. + * optional int32 value = 1 [(buf.validate.field).cel = { + * id: "my_message.value", + * message: "value must be greater than 42", + * expression: "this > 42", + * }]; + * } + * ``` + * + * @generated from field: repeated buf.validate.Rule cel = 23; + */ + cel: Rule[]; + + /** + * If `required` is true, the field must be populated. A populated field can be + * described as "serialized in the wire format," which includes: + * + * - the following "nullable" fields must be explicitly set to be considered populated: + * - singular message fields (whose fields may be unpopulated / default values) + * - member fields of a oneof (may be their default value) + * - proto3 optional fields (may be their default value) + * - proto2 scalar fields (both optional and required) + * - proto3 scalar fields must be non-zero to be considered populated + * - repeated and map fields must be non-empty to be considered populated + * + * ```proto + * message MyMessage { + * // The field `value` must be set to a non-null value. + * optional MyOtherMessage value = 1 [(buf.validate.field).required = true]; + * } + * ``` + * + * @generated from field: optional bool required = 25; + */ + required: boolean; + + /** + * Skip validation on the field if its value matches the specified criteria. + * See Ignore enum for details. + * + * ```proto + * message UpdateRequest { + * // The uri rule only applies if the field is populated and not an empty + * // string. + * optional string url = 1 [ + * (buf.validate.field).ignore = IGNORE_IF_DEFAULT_VALUE, + * (buf.validate.field).string.uri = true, + * ]; + * } + * ``` + * + * @generated from field: optional buf.validate.Ignore ignore = 27; + */ + ignore: Ignore; + + /** + * @generated from oneof buf.validate.FieldRules.type + */ + type: + | { + /** + * Scalar Field Types + * + * @generated from field: buf.validate.FloatRules float = 1; + */ + value: FloatRules; + case: "float"; + } + | { + /** + * @generated from field: buf.validate.DoubleRules double = 2; + */ + value: DoubleRules; + case: "double"; + } + | { + /** + * @generated from field: buf.validate.Int32Rules int32 = 3; + */ + value: Int32Rules; + case: "int32"; + } + | { + /** + * @generated from field: buf.validate.Int64Rules int64 = 4; + */ + value: Int64Rules; + case: "int64"; + } + | { + /** + * @generated from field: buf.validate.UInt32Rules uint32 = 5; + */ + value: UInt32Rules; + case: "uint32"; + } + | { + /** + * @generated from field: buf.validate.UInt64Rules uint64 = 6; + */ + value: UInt64Rules; + case: "uint64"; + } + | { + /** + * @generated from field: buf.validate.SInt32Rules sint32 = 7; + */ + value: SInt32Rules; + case: "sint32"; + } + | { + /** + * @generated from field: buf.validate.SInt64Rules sint64 = 8; + */ + value: SInt64Rules; + case: "sint64"; + } + | { + /** + * @generated from field: buf.validate.Fixed32Rules fixed32 = 9; + */ + value: Fixed32Rules; + case: "fixed32"; + } + | { + /** + * @generated from field: buf.validate.Fixed64Rules fixed64 = 10; + */ + value: Fixed64Rules; + case: "fixed64"; + } + | { + /** + * @generated from field: buf.validate.SFixed32Rules sfixed32 = 11; + */ + value: SFixed32Rules; + case: "sfixed32"; + } + | { + /** + * @generated from field: buf.validate.SFixed64Rules sfixed64 = 12; + */ + value: SFixed64Rules; + case: "sfixed64"; + } + | { + /** + * @generated from field: buf.validate.BoolRules bool = 13; + */ + value: BoolRules; + case: "bool"; + } + | { + /** + * @generated from field: buf.validate.StringRules string = 14; + */ + value: StringRules; + case: "string"; + } + | { + /** + * @generated from field: buf.validate.BytesRules bytes = 15; + */ + value: BytesRules; + case: "bytes"; + } + | { + /** + * Complex Field Types + * + * @generated from field: buf.validate.EnumRules enum = 16; + */ + value: EnumRules; + case: "enum"; + } + | { + /** + * @generated from field: buf.validate.RepeatedRules repeated = 18; + */ + value: RepeatedRules; + case: "repeated"; + } + | { + /** + * @generated from field: buf.validate.MapRules map = 19; + */ + value: MapRules; + case: "map"; + } + | { + /** + * Well-Known Field Types + * + * @generated from field: buf.validate.AnyRules any = 20; + */ + value: AnyRules; + case: "any"; + } + | { + /** + * @generated from field: buf.validate.DurationRules duration = 21; + */ + value: DurationRules; + case: "duration"; + } + | { + /** + * @generated from field: buf.validate.TimestampRules timestamp = 22; + */ + value: TimestampRules; + case: "timestamp"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message buf.validate.FieldRules. + * Use `create(FieldRulesSchema)` to create a new message. + */ +export const FieldRulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 3); + +/** + * PredefinedRules are custom rules that can be re-used with + * multiple fields. + * + * @generated from message buf.validate.PredefinedRules + */ +export type PredefinedRules = Message<"buf.validate.PredefinedRules"> & { + /** + * `cel` is a repeated field used to represent a textual expression + * in the Common Expression Language (CEL) syntax. For more information on + * CEL, [see our documentation](https://github.com/bufbuild/protovalidate/blob/main/docs/cel.md). + * + * ```proto + * message MyMessage { + * // The field `value` must be greater than 42. + * optional int32 value = 1 [(buf.validate.predefined).cel = { + * id: "my_message.value", + * message: "value must be greater than 42", + * expression: "this > 42", + * }]; + * } + * ``` + * + * @generated from field: repeated buf.validate.Rule cel = 1; + */ + cel: Rule[]; +}; + +/** + * Describes the message buf.validate.PredefinedRules. + * Use `create(PredefinedRulesSchema)` to create a new message. + */ +export const PredefinedRulesSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 4); + +/** + * FloatRules describes the rules applied to `float` values. These + * rules may also be applied to the `google.protobuf.FloatValue` Well-Known-Type. + * + * @generated from message buf.validate.FloatRules + */ +export type FloatRules = Message<"buf.validate.FloatRules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MyFloat { + * // value must equal 42.0 + * float value = 1 [(buf.validate.field).float.const = 42.0]; + * } + * ``` + * + * @generated from field: optional float const = 1; + */ + const: number; + + /** + * @generated from oneof buf.validate.FloatRules.less_than + */ + lessThan: + | { + /** + * `lt` requires the field value to be less than the specified value (field < + * value). If the field value is equal to or greater than the specified value, + * an error message is generated. + * + * ```proto + * message MyFloat { + * // value must be less than 10.0 + * float value = 1 [(buf.validate.field).float.lt = 10.0]; + * } + * ``` + * + * @generated from field: float lt = 2; + */ + value: number; + case: "lt"; + } + | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MyFloat { + * // value must be less than or equal to 10.0 + * float value = 1 [(buf.validate.field).float.lte = 10.0]; + * } + * ``` + * + * @generated from field: float lte = 3; + */ + value: number; + case: "lte"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.FloatRules.greater_than + */ + greaterThan: + | { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyFloat { + * // value must be greater than 5.0 [float.gt] + * float value = 1 [(buf.validate.field).float.gt = 5.0]; + * + * // value must be greater than 5 and less than 10.0 [float.gt_lt] + * float other_value = 2 [(buf.validate.field).float = { gt: 5.0, lt: 10.0 }]; + * + * // value must be greater than 10 or less than 5.0 [float.gt_lt_exclusive] + * float another_value = 3 [(buf.validate.field).float = { gt: 10.0, lt: 5.0 }]; + * } + * ``` + * + * @generated from field: float gt = 4; + */ + value: number; + case: "gt"; + } + | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyFloat { + * // value must be greater than or equal to 5.0 [float.gte] + * float value = 1 [(buf.validate.field).float.gte = 5.0]; + * + * // value must be greater than or equal to 5.0 and less than 10.0 [float.gte_lt] + * float other_value = 2 [(buf.validate.field).float = { gte: 5.0, lt: 10.0 }]; + * + * // value must be greater than or equal to 10.0 or less than 5.0 [float.gte_lt_exclusive] + * float another_value = 3 [(buf.validate.field).float = { gte: 10.0, lt: 5.0 }]; + * } + * ``` + * + * @generated from field: float gte = 5; + */ + value: number; + case: "gte"; + } + | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message + * is generated. + * + * ```proto + * message MyFloat { + * // value must be in list [1.0, 2.0, 3.0] + * float value = 1 [(buf.validate.field).float = { in: [1.0, 2.0, 3.0] }]; + * } + * ``` + * + * @generated from field: repeated float in = 6; + */ + in: number[]; + + /** + * `in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MyFloat { + * // value must not be in list [1.0, 2.0, 3.0] + * float value = 1 [(buf.validate.field).float = { not_in: [1.0, 2.0, 3.0] }]; + * } + * ``` + * + * @generated from field: repeated float not_in = 7; + */ + notIn: number[]; + + /** + * `finite` requires the field value to be finite. If the field value is + * infinite or NaN, an error message is generated. + * + * @generated from field: optional bool finite = 8; + */ + finite: boolean; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyFloat { + * float value = 1 [ + * (buf.validate.field).float.example = 1.0, + * (buf.validate.field).float.example = "Infinity" + * ]; + * } + * ``` + * + * @generated from field: repeated float example = 9; + */ + example: number[]; +}; + +/** + * Describes the message buf.validate.FloatRules. + * Use `create(FloatRulesSchema)` to create a new message. + */ +export const FloatRulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 5); + +/** + * DoubleRules describes the rules applied to `double` values. These + * rules may also be applied to the `google.protobuf.DoubleValue` Well-Known-Type. + * + * @generated from message buf.validate.DoubleRules + */ +export type DoubleRules = Message<"buf.validate.DoubleRules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MyDouble { + * // value must equal 42.0 + * double value = 1 [(buf.validate.field).double.const = 42.0]; + * } + * ``` + * + * @generated from field: optional double const = 1; + */ + const: number; + + /** + * @generated from oneof buf.validate.DoubleRules.less_than + */ + lessThan: + | { + /** + * `lt` requires the field value to be less than the specified value (field < + * value). If the field value is equal to or greater than the specified + * value, an error message is generated. + * + * ```proto + * message MyDouble { + * // value must be less than 10.0 + * double value = 1 [(buf.validate.field).double.lt = 10.0]; + * } + * ``` + * + * @generated from field: double lt = 2; + */ + value: number; + case: "lt"; + } + | { + /** + * `lte` requires the field value to be less than or equal to the specified value + * (field <= value). If the field value is greater than the specified value, + * an error message is generated. + * + * ```proto + * message MyDouble { + * // value must be less than or equal to 10.0 + * double value = 1 [(buf.validate.field).double.lte = 10.0]; + * } + * ``` + * + * @generated from field: double lte = 3; + */ + value: number; + case: "lte"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.DoubleRules.greater_than + */ + greaterThan: + | { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or `lte`, + * the range is reversed, and the field value must be outside the specified + * range. If the field value doesn't meet the required conditions, an error + * message is generated. + * + * ```proto + * message MyDouble { + * // value must be greater than 5.0 [double.gt] + * double value = 1 [(buf.validate.field).double.gt = 5.0]; + * + * // value must be greater than 5 and less than 10.0 [double.gt_lt] + * double other_value = 2 [(buf.validate.field).double = { gt: 5.0, lt: 10.0 }]; + * + * // value must be greater than 10 or less than 5.0 [double.gt_lt_exclusive] + * double another_value = 3 [(buf.validate.field).double = { gt: 10.0, lt: 5.0 }]; + * } + * ``` + * + * @generated from field: double gt = 4; + */ + value: number; + case: "gt"; + } + | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyDouble { + * // value must be greater than or equal to 5.0 [double.gte] + * double value = 1 [(buf.validate.field).double.gte = 5.0]; + * + * // value must be greater than or equal to 5.0 and less than 10.0 [double.gte_lt] + * double other_value = 2 [(buf.validate.field).double = { gte: 5.0, lt: 10.0 }]; + * + * // value must be greater than or equal to 10.0 or less than 5.0 [double.gte_lt_exclusive] + * double another_value = 3 [(buf.validate.field).double = { gte: 10.0, lt: 5.0 }]; + * } + * ``` + * + * @generated from field: double gte = 5; + */ + value: number; + case: "gte"; + } + | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message is + * generated. + * + * ```proto + * message MyDouble { + * // value must be in list [1.0, 2.0, 3.0] + * double value = 1 [(buf.validate.field).double = { in: [1.0, 2.0, 3.0] }]; + * } + * ``` + * + * @generated from field: repeated double in = 6; + */ + in: number[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MyDouble { + * // value must not be in list [1.0, 2.0, 3.0] + * double value = 1 [(buf.validate.field).double = { not_in: [1.0, 2.0, 3.0] }]; + * } + * ``` + * + * @generated from field: repeated double not_in = 7; + */ + notIn: number[]; + + /** + * `finite` requires the field value to be finite. If the field value is + * infinite or NaN, an error message is generated. + * + * @generated from field: optional bool finite = 8; + */ + finite: boolean; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyDouble { + * double value = 1 [ + * (buf.validate.field).double.example = 1.0, + * (buf.validate.field).double.example = "Infinity" + * ]; + * } + * ``` + * + * @generated from field: repeated double example = 9; + */ + example: number[]; +}; + +/** + * Describes the message buf.validate.DoubleRules. + * Use `create(DoubleRulesSchema)` to create a new message. + */ +export const DoubleRulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 6); + +/** + * Int32Rules describes the rules applied to `int32` values. These + * rules may also be applied to the `google.protobuf.Int32Value` Well-Known-Type. + * + * @generated from message buf.validate.Int32Rules + */ +export type Int32Rules = Message<"buf.validate.Int32Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MyInt32 { + * // value must equal 42 + * int32 value = 1 [(buf.validate.field).int32.const = 42]; + * } + * ``` + * + * @generated from field: optional int32 const = 1; + */ + const: number; + + /** + * @generated from oneof buf.validate.Int32Rules.less_than + */ + lessThan: + | { + /** + * `lt` requires the field value to be less than the specified value (field + * < value). If the field value is equal to or greater than the specified + * value, an error message is generated. + * + * ```proto + * message MyInt32 { + * // value must be less than 10 + * int32 value = 1 [(buf.validate.field).int32.lt = 10]; + * } + * ``` + * + * @generated from field: int32 lt = 2; + */ + value: number; + case: "lt"; + } + | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MyInt32 { + * // value must be less than or equal to 10 + * int32 value = 1 [(buf.validate.field).int32.lte = 10]; + * } + * ``` + * + * @generated from field: int32 lte = 3; + */ + value: number; + case: "lte"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.Int32Rules.greater_than + */ + greaterThan: + | { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyInt32 { + * // value must be greater than 5 [int32.gt] + * int32 value = 1 [(buf.validate.field).int32.gt = 5]; + * + * // value must be greater than 5 and less than 10 [int32.gt_lt] + * int32 other_value = 2 [(buf.validate.field).int32 = { gt: 5, lt: 10 }]; + * + * // value must be greater than 10 or less than 5 [int32.gt_lt_exclusive] + * int32 another_value = 3 [(buf.validate.field).int32 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: int32 gt = 4; + */ + value: number; + case: "gt"; + } + | { + /** + * `gte` requires the field value to be greater than or equal to the specified value + * (exclusive). If the value of `gte` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyInt32 { + * // value must be greater than or equal to 5 [int32.gte] + * int32 value = 1 [(buf.validate.field).int32.gte = 5]; + * + * // value must be greater than or equal to 5 and less than 10 [int32.gte_lt] + * int32 other_value = 2 [(buf.validate.field).int32 = { gte: 5, lt: 10 }]; + * + * // value must be greater than or equal to 10 or less than 5 [int32.gte_lt_exclusive] + * int32 another_value = 3 [(buf.validate.field).int32 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: int32 gte = 5; + */ + value: number; + case: "gte"; + } + | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message is + * generated. + * + * ```proto + * message MyInt32 { + * // value must be in list [1, 2, 3] + * int32 value = 1 [(buf.validate.field).int32 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated int32 in = 6; + */ + in: number[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error message + * is generated. + * + * ```proto + * message MyInt32 { + * // value must not be in list [1, 2, 3] + * int32 value = 1 [(buf.validate.field).int32 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated int32 not_in = 7; + */ + notIn: number[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyInt32 { + * int32 value = 1 [ + * (buf.validate.field).int32.example = 1, + * (buf.validate.field).int32.example = -10 + * ]; + * } + * ``` + * + * @generated from field: repeated int32 example = 8; + */ + example: number[]; +}; + +/** + * Describes the message buf.validate.Int32Rules. + * Use `create(Int32RulesSchema)` to create a new message. + */ +export const Int32RulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 7); + +/** + * Int64Rules describes the rules applied to `int64` values. These + * rules may also be applied to the `google.protobuf.Int64Value` Well-Known-Type. + * + * @generated from message buf.validate.Int64Rules + */ +export type Int64Rules = Message<"buf.validate.Int64Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MyInt64 { + * // value must equal 42 + * int64 value = 1 [(buf.validate.field).int64.const = 42]; + * } + * ``` + * + * @generated from field: optional int64 const = 1; + */ + const: bigint; + + /** + * @generated from oneof buf.validate.Int64Rules.less_than + */ + lessThan: + | { + /** + * `lt` requires the field value to be less than the specified value (field < + * value). If the field value is equal to or greater than the specified value, + * an error message is generated. + * + * ```proto + * message MyInt64 { + * // value must be less than 10 + * int64 value = 1 [(buf.validate.field).int64.lt = 10]; + * } + * ``` + * + * @generated from field: int64 lt = 2; + */ + value: bigint; + case: "lt"; + } + | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MyInt64 { + * // value must be less than or equal to 10 + * int64 value = 1 [(buf.validate.field).int64.lte = 10]; + * } + * ``` + * + * @generated from field: int64 lte = 3; + */ + value: bigint; + case: "lte"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.Int64Rules.greater_than + */ + greaterThan: + | { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyInt64 { + * // value must be greater than 5 [int64.gt] + * int64 value = 1 [(buf.validate.field).int64.gt = 5]; + * + * // value must be greater than 5 and less than 10 [int64.gt_lt] + * int64 other_value = 2 [(buf.validate.field).int64 = { gt: 5, lt: 10 }]; + * + * // value must be greater than 10 or less than 5 [int64.gt_lt_exclusive] + * int64 another_value = 3 [(buf.validate.field).int64 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: int64 gt = 4; + */ + value: bigint; + case: "gt"; + } + | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyInt64 { + * // value must be greater than or equal to 5 [int64.gte] + * int64 value = 1 [(buf.validate.field).int64.gte = 5]; + * + * // value must be greater than or equal to 5 and less than 10 [int64.gte_lt] + * int64 other_value = 2 [(buf.validate.field).int64 = { gte: 5, lt: 10 }]; + * + * // value must be greater than or equal to 10 or less than 5 [int64.gte_lt_exclusive] + * int64 another_value = 3 [(buf.validate.field).int64 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: int64 gte = 5; + */ + value: bigint; + case: "gte"; + } + | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message is + * generated. + * + * ```proto + * message MyInt64 { + * // value must be in list [1, 2, 3] + * int64 value = 1 [(buf.validate.field).int64 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated int64 in = 6; + */ + in: bigint[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MyInt64 { + * // value must not be in list [1, 2, 3] + * int64 value = 1 [(buf.validate.field).int64 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated int64 not_in = 7; + */ + notIn: bigint[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyInt64 { + * int64 value = 1 [ + * (buf.validate.field).int64.example = 1, + * (buf.validate.field).int64.example = -10 + * ]; + * } + * ``` + * + * @generated from field: repeated int64 example = 9; + */ + example: bigint[]; +}; + +/** + * Describes the message buf.validate.Int64Rules. + * Use `create(Int64RulesSchema)` to create a new message. + */ +export const Int64RulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 8); + +/** + * UInt32Rules describes the rules applied to `uint32` values. These + * rules may also be applied to the `google.protobuf.UInt32Value` Well-Known-Type. + * + * @generated from message buf.validate.UInt32Rules + */ +export type UInt32Rules = Message<"buf.validate.UInt32Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MyUInt32 { + * // value must equal 42 + * uint32 value = 1 [(buf.validate.field).uint32.const = 42]; + * } + * ``` + * + * @generated from field: optional uint32 const = 1; + */ + const: number; + + /** + * @generated from oneof buf.validate.UInt32Rules.less_than + */ + lessThan: + | { + /** + * `lt` requires the field value to be less than the specified value (field < + * value). If the field value is equal to or greater than the specified value, + * an error message is generated. + * + * ```proto + * message MyUInt32 { + * // value must be less than 10 + * uint32 value = 1 [(buf.validate.field).uint32.lt = 10]; + * } + * ``` + * + * @generated from field: uint32 lt = 2; + */ + value: number; + case: "lt"; + } + | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MyUInt32 { + * // value must be less than or equal to 10 + * uint32 value = 1 [(buf.validate.field).uint32.lte = 10]; + * } + * ``` + * + * @generated from field: uint32 lte = 3; + */ + value: number; + case: "lte"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.UInt32Rules.greater_than + */ + greaterThan: + | { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyUInt32 { + * // value must be greater than 5 [uint32.gt] + * uint32 value = 1 [(buf.validate.field).uint32.gt = 5]; + * + * // value must be greater than 5 and less than 10 [uint32.gt_lt] + * uint32 other_value = 2 [(buf.validate.field).uint32 = { gt: 5, lt: 10 }]; + * + * // value must be greater than 10 or less than 5 [uint32.gt_lt_exclusive] + * uint32 another_value = 3 [(buf.validate.field).uint32 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: uint32 gt = 4; + */ + value: number; + case: "gt"; + } + | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyUInt32 { + * // value must be greater than or equal to 5 [uint32.gte] + * uint32 value = 1 [(buf.validate.field).uint32.gte = 5]; + * + * // value must be greater than or equal to 5 and less than 10 [uint32.gte_lt] + * uint32 other_value = 2 [(buf.validate.field).uint32 = { gte: 5, lt: 10 }]; + * + * // value must be greater than or equal to 10 or less than 5 [uint32.gte_lt_exclusive] + * uint32 another_value = 3 [(buf.validate.field).uint32 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: uint32 gte = 5; + */ + value: number; + case: "gte"; + } + | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message is + * generated. + * + * ```proto + * message MyUInt32 { + * // value must be in list [1, 2, 3] + * uint32 value = 1 [(buf.validate.field).uint32 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated uint32 in = 6; + */ + in: number[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MyUInt32 { + * // value must not be in list [1, 2, 3] + * uint32 value = 1 [(buf.validate.field).uint32 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated uint32 not_in = 7; + */ + notIn: number[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyUInt32 { + * uint32 value = 1 [ + * (buf.validate.field).uint32.example = 1, + * (buf.validate.field).uint32.example = 10 + * ]; + * } + * ``` + * + * @generated from field: repeated uint32 example = 8; + */ + example: number[]; +}; + +/** + * Describes the message buf.validate.UInt32Rules. + * Use `create(UInt32RulesSchema)` to create a new message. + */ +export const UInt32RulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 9); + +/** + * UInt64Rules describes the rules applied to `uint64` values. These + * rules may also be applied to the `google.protobuf.UInt64Value` Well-Known-Type. + * + * @generated from message buf.validate.UInt64Rules + */ +export type UInt64Rules = Message<"buf.validate.UInt64Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MyUInt64 { + * // value must equal 42 + * uint64 value = 1 [(buf.validate.field).uint64.const = 42]; + * } + * ``` + * + * @generated from field: optional uint64 const = 1; + */ + const: bigint; + + /** + * @generated from oneof buf.validate.UInt64Rules.less_than + */ + lessThan: + | { + /** + * `lt` requires the field value to be less than the specified value (field < + * value). If the field value is equal to or greater than the specified value, + * an error message is generated. + * + * ```proto + * message MyUInt64 { + * // value must be less than 10 + * uint64 value = 1 [(buf.validate.field).uint64.lt = 10]; + * } + * ``` + * + * @generated from field: uint64 lt = 2; + */ + value: bigint; + case: "lt"; + } + | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MyUInt64 { + * // value must be less than or equal to 10 + * uint64 value = 1 [(buf.validate.field).uint64.lte = 10]; + * } + * ``` + * + * @generated from field: uint64 lte = 3; + */ + value: bigint; + case: "lte"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.UInt64Rules.greater_than + */ + greaterThan: + | { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyUInt64 { + * // value must be greater than 5 [uint64.gt] + * uint64 value = 1 [(buf.validate.field).uint64.gt = 5]; + * + * // value must be greater than 5 and less than 10 [uint64.gt_lt] + * uint64 other_value = 2 [(buf.validate.field).uint64 = { gt: 5, lt: 10 }]; + * + * // value must be greater than 10 or less than 5 [uint64.gt_lt_exclusive] + * uint64 another_value = 3 [(buf.validate.field).uint64 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: uint64 gt = 4; + */ + value: bigint; + case: "gt"; + } + | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyUInt64 { + * // value must be greater than or equal to 5 [uint64.gte] + * uint64 value = 1 [(buf.validate.field).uint64.gte = 5]; + * + * // value must be greater than or equal to 5 and less than 10 [uint64.gte_lt] + * uint64 other_value = 2 [(buf.validate.field).uint64 = { gte: 5, lt: 10 }]; + * + * // value must be greater than or equal to 10 or less than 5 [uint64.gte_lt_exclusive] + * uint64 another_value = 3 [(buf.validate.field).uint64 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: uint64 gte = 5; + */ + value: bigint; + case: "gte"; + } + | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message is + * generated. + * + * ```proto + * message MyUInt64 { + * // value must be in list [1, 2, 3] + * uint64 value = 1 [(buf.validate.field).uint64 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated uint64 in = 6; + */ + in: bigint[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MyUInt64 { + * // value must not be in list [1, 2, 3] + * uint64 value = 1 [(buf.validate.field).uint64 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated uint64 not_in = 7; + */ + notIn: bigint[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyUInt64 { + * uint64 value = 1 [ + * (buf.validate.field).uint64.example = 1, + * (buf.validate.field).uint64.example = -10 + * ]; + * } + * ``` + * + * @generated from field: repeated uint64 example = 8; + */ + example: bigint[]; +}; + +/** + * Describes the message buf.validate.UInt64Rules. + * Use `create(UInt64RulesSchema)` to create a new message. + */ +export const UInt64RulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 10); + +/** + * SInt32Rules describes the rules applied to `sint32` values. + * + * @generated from message buf.validate.SInt32Rules + */ +export type SInt32Rules = Message<"buf.validate.SInt32Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MySInt32 { + * // value must equal 42 + * sint32 value = 1 [(buf.validate.field).sint32.const = 42]; + * } + * ``` + * + * @generated from field: optional sint32 const = 1; + */ + const: number; + + /** + * @generated from oneof buf.validate.SInt32Rules.less_than + */ + lessThan: + | { + /** + * `lt` requires the field value to be less than the specified value (field + * < value). If the field value is equal to or greater than the specified + * value, an error message is generated. + * + * ```proto + * message MySInt32 { + * // value must be less than 10 + * sint32 value = 1 [(buf.validate.field).sint32.lt = 10]; + * } + * ``` + * + * @generated from field: sint32 lt = 2; + */ + value: number; + case: "lt"; + } + | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MySInt32 { + * // value must be less than or equal to 10 + * sint32 value = 1 [(buf.validate.field).sint32.lte = 10]; + * } + * ``` + * + * @generated from field: sint32 lte = 3; + */ + value: number; + case: "lte"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.SInt32Rules.greater_than + */ + greaterThan: + | { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MySInt32 { + * // value must be greater than 5 [sint32.gt] + * sint32 value = 1 [(buf.validate.field).sint32.gt = 5]; + * + * // value must be greater than 5 and less than 10 [sint32.gt_lt] + * sint32 other_value = 2 [(buf.validate.field).sint32 = { gt: 5, lt: 10 }]; + * + * // value must be greater than 10 or less than 5 [sint32.gt_lt_exclusive] + * sint32 another_value = 3 [(buf.validate.field).sint32 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: sint32 gt = 4; + */ + value: number; + case: "gt"; + } + | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MySInt32 { + * // value must be greater than or equal to 5 [sint32.gte] + * sint32 value = 1 [(buf.validate.field).sint32.gte = 5]; + * + * // value must be greater than or equal to 5 and less than 10 [sint32.gte_lt] + * sint32 other_value = 2 [(buf.validate.field).sint32 = { gte: 5, lt: 10 }]; + * + * // value must be greater than or equal to 10 or less than 5 [sint32.gte_lt_exclusive] + * sint32 another_value = 3 [(buf.validate.field).sint32 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: sint32 gte = 5; + */ + value: number; + case: "gte"; + } + | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message is + * generated. + * + * ```proto + * message MySInt32 { + * // value must be in list [1, 2, 3] + * sint32 value = 1 [(buf.validate.field).sint32 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated sint32 in = 6; + */ + in: number[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MySInt32 { + * // value must not be in list [1, 2, 3] + * sint32 value = 1 [(buf.validate.field).sint32 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated sint32 not_in = 7; + */ + notIn: number[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MySInt32 { + * sint32 value = 1 [ + * (buf.validate.field).sint32.example = 1, + * (buf.validate.field).sint32.example = -10 + * ]; + * } + * ``` + * + * @generated from field: repeated sint32 example = 8; + */ + example: number[]; +}; + +/** + * Describes the message buf.validate.SInt32Rules. + * Use `create(SInt32RulesSchema)` to create a new message. + */ +export const SInt32RulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 11); + +/** + * SInt64Rules describes the rules applied to `sint64` values. + * + * @generated from message buf.validate.SInt64Rules + */ +export type SInt64Rules = Message<"buf.validate.SInt64Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MySInt64 { + * // value must equal 42 + * sint64 value = 1 [(buf.validate.field).sint64.const = 42]; + * } + * ``` + * + * @generated from field: optional sint64 const = 1; + */ + const: bigint; + + /** + * @generated from oneof buf.validate.SInt64Rules.less_than + */ + lessThan: + | { + /** + * `lt` requires the field value to be less than the specified value (field + * < value). If the field value is equal to or greater than the specified + * value, an error message is generated. + * + * ```proto + * message MySInt64 { + * // value must be less than 10 + * sint64 value = 1 [(buf.validate.field).sint64.lt = 10]; + * } + * ``` + * + * @generated from field: sint64 lt = 2; + */ + value: bigint; + case: "lt"; + } + | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MySInt64 { + * // value must be less than or equal to 10 + * sint64 value = 1 [(buf.validate.field).sint64.lte = 10]; + * } + * ``` + * + * @generated from field: sint64 lte = 3; + */ + value: bigint; + case: "lte"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.SInt64Rules.greater_than + */ + greaterThan: + | { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MySInt64 { + * // value must be greater than 5 [sint64.gt] + * sint64 value = 1 [(buf.validate.field).sint64.gt = 5]; + * + * // value must be greater than 5 and less than 10 [sint64.gt_lt] + * sint64 other_value = 2 [(buf.validate.field).sint64 = { gt: 5, lt: 10 }]; + * + * // value must be greater than 10 or less than 5 [sint64.gt_lt_exclusive] + * sint64 another_value = 3 [(buf.validate.field).sint64 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: sint64 gt = 4; + */ + value: bigint; + case: "gt"; + } + | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MySInt64 { + * // value must be greater than or equal to 5 [sint64.gte] + * sint64 value = 1 [(buf.validate.field).sint64.gte = 5]; + * + * // value must be greater than or equal to 5 and less than 10 [sint64.gte_lt] + * sint64 other_value = 2 [(buf.validate.field).sint64 = { gte: 5, lt: 10 }]; + * + * // value must be greater than or equal to 10 or less than 5 [sint64.gte_lt_exclusive] + * sint64 another_value = 3 [(buf.validate.field).sint64 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: sint64 gte = 5; + */ + value: bigint; + case: "gte"; + } + | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message + * is generated. + * + * ```proto + * message MySInt64 { + * // value must be in list [1, 2, 3] + * sint64 value = 1 [(buf.validate.field).sint64 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated sint64 in = 6; + */ + in: bigint[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MySInt64 { + * // value must not be in list [1, 2, 3] + * sint64 value = 1 [(buf.validate.field).sint64 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated sint64 not_in = 7; + */ + notIn: bigint[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MySInt64 { + * sint64 value = 1 [ + * (buf.validate.field).sint64.example = 1, + * (buf.validate.field).sint64.example = -10 + * ]; + * } + * ``` + * + * @generated from field: repeated sint64 example = 8; + */ + example: bigint[]; +}; + +/** + * Describes the message buf.validate.SInt64Rules. + * Use `create(SInt64RulesSchema)` to create a new message. + */ +export const SInt64RulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 12); + +/** + * Fixed32Rules describes the rules applied to `fixed32` values. + * + * @generated from message buf.validate.Fixed32Rules + */ +export type Fixed32Rules = Message<"buf.validate.Fixed32Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. + * If the field value doesn't match, an error message is generated. + * + * ```proto + * message MyFixed32 { + * // value must equal 42 + * fixed32 value = 1 [(buf.validate.field).fixed32.const = 42]; + * } + * ``` + * + * @generated from field: optional fixed32 const = 1; + */ + const: number; + + /** + * @generated from oneof buf.validate.Fixed32Rules.less_than + */ + lessThan: + | { + /** + * `lt` requires the field value to be less than the specified value (field < + * value). If the field value is equal to or greater than the specified value, + * an error message is generated. + * + * ```proto + * message MyFixed32 { + * // value must be less than 10 + * fixed32 value = 1 [(buf.validate.field).fixed32.lt = 10]; + * } + * ``` + * + * @generated from field: fixed32 lt = 2; + */ + value: number; + case: "lt"; + } + | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MyFixed32 { + * // value must be less than or equal to 10 + * fixed32 value = 1 [(buf.validate.field).fixed32.lte = 10]; + * } + * ``` + * + * @generated from field: fixed32 lte = 3; + */ + value: number; + case: "lte"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.Fixed32Rules.greater_than + */ + greaterThan: + | { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyFixed32 { + * // value must be greater than 5 [fixed32.gt] + * fixed32 value = 1 [(buf.validate.field).fixed32.gt = 5]; + * + * // value must be greater than 5 and less than 10 [fixed32.gt_lt] + * fixed32 other_value = 2 [(buf.validate.field).fixed32 = { gt: 5, lt: 10 }]; + * + * // value must be greater than 10 or less than 5 [fixed32.gt_lt_exclusive] + * fixed32 another_value = 3 [(buf.validate.field).fixed32 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: fixed32 gt = 4; + */ + value: number; + case: "gt"; + } + | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyFixed32 { + * // value must be greater than or equal to 5 [fixed32.gte] + * fixed32 value = 1 [(buf.validate.field).fixed32.gte = 5]; + * + * // value must be greater than or equal to 5 and less than 10 [fixed32.gte_lt] + * fixed32 other_value = 2 [(buf.validate.field).fixed32 = { gte: 5, lt: 10 }]; + * + * // value must be greater than or equal to 10 or less than 5 [fixed32.gte_lt_exclusive] + * fixed32 another_value = 3 [(buf.validate.field).fixed32 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: fixed32 gte = 5; + */ + value: number; + case: "gte"; + } + | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message + * is generated. + * + * ```proto + * message MyFixed32 { + * // value must be in list [1, 2, 3] + * fixed32 value = 1 [(buf.validate.field).fixed32 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated fixed32 in = 6; + */ + in: number[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MyFixed32 { + * // value must not be in list [1, 2, 3] + * fixed32 value = 1 [(buf.validate.field).fixed32 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated fixed32 not_in = 7; + */ + notIn: number[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyFixed32 { + * fixed32 value = 1 [ + * (buf.validate.field).fixed32.example = 1, + * (buf.validate.field).fixed32.example = 2 + * ]; + * } + * ``` + * + * @generated from field: repeated fixed32 example = 8; + */ + example: number[]; +}; + +/** + * Describes the message buf.validate.Fixed32Rules. + * Use `create(Fixed32RulesSchema)` to create a new message. + */ +export const Fixed32RulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 13); + +/** + * Fixed64Rules describes the rules applied to `fixed64` values. + * + * @generated from message buf.validate.Fixed64Rules + */ +export type Fixed64Rules = Message<"buf.validate.Fixed64Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MyFixed64 { + * // value must equal 42 + * fixed64 value = 1 [(buf.validate.field).fixed64.const = 42]; + * } + * ``` + * + * @generated from field: optional fixed64 const = 1; + */ + const: bigint; + + /** + * @generated from oneof buf.validate.Fixed64Rules.less_than + */ + lessThan: + | { + /** + * `lt` requires the field value to be less than the specified value (field < + * value). If the field value is equal to or greater than the specified value, + * an error message is generated. + * + * ```proto + * message MyFixed64 { + * // value must be less than 10 + * fixed64 value = 1 [(buf.validate.field).fixed64.lt = 10]; + * } + * ``` + * + * @generated from field: fixed64 lt = 2; + */ + value: bigint; + case: "lt"; + } + | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MyFixed64 { + * // value must be less than or equal to 10 + * fixed64 value = 1 [(buf.validate.field).fixed64.lte = 10]; + * } + * ``` + * + * @generated from field: fixed64 lte = 3; + */ + value: bigint; + case: "lte"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.Fixed64Rules.greater_than + */ + greaterThan: + | { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyFixed64 { + * // value must be greater than 5 [fixed64.gt] + * fixed64 value = 1 [(buf.validate.field).fixed64.gt = 5]; + * + * // value must be greater than 5 and less than 10 [fixed64.gt_lt] + * fixed64 other_value = 2 [(buf.validate.field).fixed64 = { gt: 5, lt: 10 }]; + * + * // value must be greater than 10 or less than 5 [fixed64.gt_lt_exclusive] + * fixed64 another_value = 3 [(buf.validate.field).fixed64 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: fixed64 gt = 4; + */ + value: bigint; + case: "gt"; + } + | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyFixed64 { + * // value must be greater than or equal to 5 [fixed64.gte] + * fixed64 value = 1 [(buf.validate.field).fixed64.gte = 5]; + * + * // value must be greater than or equal to 5 and less than 10 [fixed64.gte_lt] + * fixed64 other_value = 2 [(buf.validate.field).fixed64 = { gte: 5, lt: 10 }]; + * + * // value must be greater than or equal to 10 or less than 5 [fixed64.gte_lt_exclusive] + * fixed64 another_value = 3 [(buf.validate.field).fixed64 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: fixed64 gte = 5; + */ + value: bigint; + case: "gte"; + } + | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message is + * generated. + * + * ```proto + * message MyFixed64 { + * // value must be in list [1, 2, 3] + * fixed64 value = 1 [(buf.validate.field).fixed64 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated fixed64 in = 6; + */ + in: bigint[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MyFixed64 { + * // value must not be in list [1, 2, 3] + * fixed64 value = 1 [(buf.validate.field).fixed64 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated fixed64 not_in = 7; + */ + notIn: bigint[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyFixed64 { + * fixed64 value = 1 [ + * (buf.validate.field).fixed64.example = 1, + * (buf.validate.field).fixed64.example = 2 + * ]; + * } + * ``` + * + * @generated from field: repeated fixed64 example = 8; + */ + example: bigint[]; +}; + +/** + * Describes the message buf.validate.Fixed64Rules. + * Use `create(Fixed64RulesSchema)` to create a new message. + */ +export const Fixed64RulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 14); + +/** + * SFixed32Rules describes the rules applied to `fixed32` values. + * + * @generated from message buf.validate.SFixed32Rules + */ +export type SFixed32Rules = Message<"buf.validate.SFixed32Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MySFixed32 { + * // value must equal 42 + * sfixed32 value = 1 [(buf.validate.field).sfixed32.const = 42]; + * } + * ``` + * + * @generated from field: optional sfixed32 const = 1; + */ + const: number; + + /** + * @generated from oneof buf.validate.SFixed32Rules.less_than + */ + lessThan: + | { + /** + * `lt` requires the field value to be less than the specified value (field < + * value). If the field value is equal to or greater than the specified value, + * an error message is generated. + * + * ```proto + * message MySFixed32 { + * // value must be less than 10 + * sfixed32 value = 1 [(buf.validate.field).sfixed32.lt = 10]; + * } + * ``` + * + * @generated from field: sfixed32 lt = 2; + */ + value: number; + case: "lt"; + } + | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MySFixed32 { + * // value must be less than or equal to 10 + * sfixed32 value = 1 [(buf.validate.field).sfixed32.lte = 10]; + * } + * ``` + * + * @generated from field: sfixed32 lte = 3; + */ + value: number; + case: "lte"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.SFixed32Rules.greater_than + */ + greaterThan: + | { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MySFixed32 { + * // value must be greater than 5 [sfixed32.gt] + * sfixed32 value = 1 [(buf.validate.field).sfixed32.gt = 5]; + * + * // value must be greater than 5 and less than 10 [sfixed32.gt_lt] + * sfixed32 other_value = 2 [(buf.validate.field).sfixed32 = { gt: 5, lt: 10 }]; + * + * // value must be greater than 10 or less than 5 [sfixed32.gt_lt_exclusive] + * sfixed32 another_value = 3 [(buf.validate.field).sfixed32 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: sfixed32 gt = 4; + */ + value: number; + case: "gt"; + } + | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MySFixed32 { + * // value must be greater than or equal to 5 [sfixed32.gte] + * sfixed32 value = 1 [(buf.validate.field).sfixed32.gte = 5]; + * + * // value must be greater than or equal to 5 and less than 10 [sfixed32.gte_lt] + * sfixed32 other_value = 2 [(buf.validate.field).sfixed32 = { gte: 5, lt: 10 }]; + * + * // value must be greater than or equal to 10 or less than 5 [sfixed32.gte_lt_exclusive] + * sfixed32 another_value = 3 [(buf.validate.field).sfixed32 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: sfixed32 gte = 5; + */ + value: number; + case: "gte"; + } + | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message is + * generated. + * + * ```proto + * message MySFixed32 { + * // value must be in list [1, 2, 3] + * sfixed32 value = 1 [(buf.validate.field).sfixed32 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated sfixed32 in = 6; + */ + in: number[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MySFixed32 { + * // value must not be in list [1, 2, 3] + * sfixed32 value = 1 [(buf.validate.field).sfixed32 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated sfixed32 not_in = 7; + */ + notIn: number[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MySFixed32 { + * sfixed32 value = 1 [ + * (buf.validate.field).sfixed32.example = 1, + * (buf.validate.field).sfixed32.example = 2 + * ]; + * } + * ``` + * + * @generated from field: repeated sfixed32 example = 8; + */ + example: number[]; +}; + +/** + * Describes the message buf.validate.SFixed32Rules. + * Use `create(SFixed32RulesSchema)` to create a new message. + */ +export const SFixed32RulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 15); + +/** + * SFixed64Rules describes the rules applied to `fixed64` values. + * + * @generated from message buf.validate.SFixed64Rules + */ +export type SFixed64Rules = Message<"buf.validate.SFixed64Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MySFixed64 { + * // value must equal 42 + * sfixed64 value = 1 [(buf.validate.field).sfixed64.const = 42]; + * } + * ``` + * + * @generated from field: optional sfixed64 const = 1; + */ + const: bigint; + + /** + * @generated from oneof buf.validate.SFixed64Rules.less_than + */ + lessThan: + | { + /** + * `lt` requires the field value to be less than the specified value (field < + * value). If the field value is equal to or greater than the specified value, + * an error message is generated. + * + * ```proto + * message MySFixed64 { + * // value must be less than 10 + * sfixed64 value = 1 [(buf.validate.field).sfixed64.lt = 10]; + * } + * ``` + * + * @generated from field: sfixed64 lt = 2; + */ + value: bigint; + case: "lt"; + } + | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MySFixed64 { + * // value must be less than or equal to 10 + * sfixed64 value = 1 [(buf.validate.field).sfixed64.lte = 10]; + * } + * ``` + * + * @generated from field: sfixed64 lte = 3; + */ + value: bigint; + case: "lte"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.SFixed64Rules.greater_than + */ + greaterThan: + | { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MySFixed64 { + * // value must be greater than 5 [sfixed64.gt] + * sfixed64 value = 1 [(buf.validate.field).sfixed64.gt = 5]; + * + * // value must be greater than 5 and less than 10 [sfixed64.gt_lt] + * sfixed64 other_value = 2 [(buf.validate.field).sfixed64 = { gt: 5, lt: 10 }]; + * + * // value must be greater than 10 or less than 5 [sfixed64.gt_lt_exclusive] + * sfixed64 another_value = 3 [(buf.validate.field).sfixed64 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: sfixed64 gt = 4; + */ + value: bigint; + case: "gt"; + } + | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MySFixed64 { + * // value must be greater than or equal to 5 [sfixed64.gte] + * sfixed64 value = 1 [(buf.validate.field).sfixed64.gte = 5]; + * + * // value must be greater than or equal to 5 and less than 10 [sfixed64.gte_lt] + * sfixed64 other_value = 2 [(buf.validate.field).sfixed64 = { gte: 5, lt: 10 }]; + * + * // value must be greater than or equal to 10 or less than 5 [sfixed64.gte_lt_exclusive] + * sfixed64 another_value = 3 [(buf.validate.field).sfixed64 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: sfixed64 gte = 5; + */ + value: bigint; + case: "gte"; + } + | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message is + * generated. + * + * ```proto + * message MySFixed64 { + * // value must be in list [1, 2, 3] + * sfixed64 value = 1 [(buf.validate.field).sfixed64 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated sfixed64 in = 6; + */ + in: bigint[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MySFixed64 { + * // value must not be in list [1, 2, 3] + * sfixed64 value = 1 [(buf.validate.field).sfixed64 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated sfixed64 not_in = 7; + */ + notIn: bigint[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MySFixed64 { + * sfixed64 value = 1 [ + * (buf.validate.field).sfixed64.example = 1, + * (buf.validate.field).sfixed64.example = 2 + * ]; + * } + * ``` + * + * @generated from field: repeated sfixed64 example = 8; + */ + example: bigint[]; +}; + +/** + * Describes the message buf.validate.SFixed64Rules. + * Use `create(SFixed64RulesSchema)` to create a new message. + */ +export const SFixed64RulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 16); + +/** + * BoolRules describes the rules applied to `bool` values. These rules + * may also be applied to the `google.protobuf.BoolValue` Well-Known-Type. + * + * @generated from message buf.validate.BoolRules + */ +export type BoolRules = Message<"buf.validate.BoolRules"> & { + /** + * `const` requires the field value to exactly match the specified boolean value. + * If the field value doesn't match, an error message is generated. + * + * ```proto + * message MyBool { + * // value must equal true + * bool value = 1 [(buf.validate.field).bool.const = true]; + * } + * ``` + * + * @generated from field: optional bool const = 1; + */ + const: boolean; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyBool { + * bool value = 1 [ + * (buf.validate.field).bool.example = 1, + * (buf.validate.field).bool.example = 2 + * ]; + * } + * ``` + * + * @generated from field: repeated bool example = 2; + */ + example: boolean[]; +}; + +/** + * Describes the message buf.validate.BoolRules. + * Use `create(BoolRulesSchema)` to create a new message. + */ +export const BoolRulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 17); + +/** + * StringRules describes the rules applied to `string` values These + * rules may also be applied to the `google.protobuf.StringValue` Well-Known-Type. + * + * @generated from message buf.validate.StringRules + */ +export type StringRules = Message<"buf.validate.StringRules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MyString { + * // value must equal `hello` + * string value = 1 [(buf.validate.field).string.const = "hello"]; + * } + * ``` + * + * @generated from field: optional string const = 1; + */ + const: string; + + /** + * `len` dictates that the field value must have the specified + * number of characters (Unicode code points), which may differ from the number + * of bytes in the string. If the field value does not meet the specified + * length, an error message will be generated. + * + * ```proto + * message MyString { + * // value length must be 5 characters + * string value = 1 [(buf.validate.field).string.len = 5]; + * } + * ``` + * + * @generated from field: optional uint64 len = 19; + */ + len: bigint; + + /** + * `min_len` specifies that the field value must have at least the specified + * number of characters (Unicode code points), which may differ from the number + * of bytes in the string. If the field value contains fewer characters, an error + * message will be generated. + * + * ```proto + * message MyString { + * // value length must be at least 3 characters + * string value = 1 [(buf.validate.field).string.min_len = 3]; + * } + * ``` + * + * @generated from field: optional uint64 min_len = 2; + */ + minLen: bigint; + + /** + * `max_len` specifies that the field value must have no more than the specified + * number of characters (Unicode code points), which may differ from the + * number of bytes in the string. If the field value contains more characters, + * an error message will be generated. + * + * ```proto + * message MyString { + * // value length must be at most 10 characters + * string value = 1 [(buf.validate.field).string.max_len = 10]; + * } + * ``` + * + * @generated from field: optional uint64 max_len = 3; + */ + maxLen: bigint; + + /** + * `len_bytes` dictates that the field value must have the specified number of + * bytes. If the field value does not match the specified length in bytes, + * an error message will be generated. + * + * ```proto + * message MyString { + * // value length must be 6 bytes + * string value = 1 [(buf.validate.field).string.len_bytes = 6]; + * } + * ``` + * + * @generated from field: optional uint64 len_bytes = 20; + */ + lenBytes: bigint; + + /** + * `min_bytes` specifies that the field value must have at least the specified + * number of bytes. If the field value contains fewer bytes, an error message + * will be generated. + * + * ```proto + * message MyString { + * // value length must be at least 4 bytes + * string value = 1 [(buf.validate.field).string.min_bytes = 4]; + * } + * + * ``` + * + * @generated from field: optional uint64 min_bytes = 4; + */ + minBytes: bigint; + + /** + * `max_bytes` specifies that the field value must have no more than the + * specified number of bytes. If the field value contains more bytes, an + * error message will be generated. + * + * ```proto + * message MyString { + * // value length must be at most 8 bytes + * string value = 1 [(buf.validate.field).string.max_bytes = 8]; + * } + * ``` + * + * @generated from field: optional uint64 max_bytes = 5; + */ + maxBytes: bigint; + + /** + * `pattern` specifies that the field value must match the specified + * regular expression (RE2 syntax), with the expression provided without any + * delimiters. If the field value doesn't match the regular expression, an + * error message will be generated. + * + * ```proto + * message MyString { + * // value does not match regex pattern `^[a-zA-Z]//$` + * string value = 1 [(buf.validate.field).string.pattern = "^[a-zA-Z]//$"]; + * } + * ``` + * + * @generated from field: optional string pattern = 6; + */ + pattern: string; + + /** + * `prefix` specifies that the field value must have the + * specified substring at the beginning of the string. If the field value + * doesn't start with the specified prefix, an error message will be + * generated. + * + * ```proto + * message MyString { + * // value does not have prefix `pre` + * string value = 1 [(buf.validate.field).string.prefix = "pre"]; + * } + * ``` + * + * @generated from field: optional string prefix = 7; + */ + prefix: string; + + /** + * `suffix` specifies that the field value must have the + * specified substring at the end of the string. If the field value doesn't + * end with the specified suffix, an error message will be generated. + * + * ```proto + * message MyString { + * // value does not have suffix `post` + * string value = 1 [(buf.validate.field).string.suffix = "post"]; + * } + * ``` + * + * @generated from field: optional string suffix = 8; + */ + suffix: string; + + /** + * `contains` specifies that the field value must have the + * specified substring anywhere in the string. If the field value doesn't + * contain the specified substring, an error message will be generated. + * + * ```proto + * message MyString { + * // value does not contain substring `inside`. + * string value = 1 [(buf.validate.field).string.contains = "inside"]; + * } + * ``` + * + * @generated from field: optional string contains = 9; + */ + contains: string; + + /** + * `not_contains` specifies that the field value must not have the + * specified substring anywhere in the string. If the field value contains + * the specified substring, an error message will be generated. + * + * ```proto + * message MyString { + * // value contains substring `inside`. + * string value = 1 [(buf.validate.field).string.not_contains = "inside"]; + * } + * ``` + * + * @generated from field: optional string not_contains = 23; + */ + notContains: string; + + /** + * `in` specifies that the field value must be equal to one of the specified + * values. If the field value isn't one of the specified values, an error + * message will be generated. + * + * ```proto + * message MyString { + * // value must be in list ["apple", "banana"] + * string value = 1 [(buf.validate.field).string.in = "apple", (buf.validate.field).string.in = "banana"]; + * } + * ``` + * + * @generated from field: repeated string in = 10; + */ + in: string[]; + + /** + * `not_in` specifies that the field value cannot be equal to any + * of the specified values. If the field value is one of the specified values, + * an error message will be generated. + * ```proto + * message MyString { + * // value must not be in list ["orange", "grape"] + * string value = 1 [(buf.validate.field).string.not_in = "orange", (buf.validate.field).string.not_in = "grape"]; + * } + * ``` + * + * @generated from field: repeated string not_in = 11; + */ + notIn: string[]; + + /** + * `WellKnown` rules provide advanced rules against common string + * patterns. + * + * @generated from oneof buf.validate.StringRules.well_known + */ + wellKnown: + | { + /** + * `email` specifies that the field value must be a valid email address, for + * example "foo@example.com". + * + * Conforms to the definition for a valid email address from the [HTML standard](https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address). + * Note that this standard willfully deviates from [RFC 5322](https://datatracker.ietf.org/doc/html/rfc5322), + * which allows many unexpected forms of email addresses and will easily match + * a typographical error. + * + * If the field value isn't a valid email address, an error message will be generated. + * + * ```proto + * message MyString { + * // value must be a valid email address + * string value = 1 [(buf.validate.field).string.email = true]; + * } + * ``` + * + * @generated from field: bool email = 12; + */ + value: boolean; + case: "email"; + } + | { + /** + * `hostname` specifies that the field value must be a valid hostname, for + * example "foo.example.com". + * + * A valid hostname follows the rules below: + * - The name consists of one or more labels, separated by a dot ("."). + * - Each label can be 1 to 63 alphanumeric characters. + * - A label can contain hyphens ("-"), but must not start or end with a hyphen. + * - The right-most label must not be digits only. + * - The name can have a trailing dot—for example, "foo.example.com.". + * - The name can be 253 characters at most, excluding the optional trailing dot. + * + * If the field value isn't a valid hostname, an error message will be generated. + * + * ```proto + * message MyString { + * // value must be a valid hostname + * string value = 1 [(buf.validate.field).string.hostname = true]; + * } + * ``` + * + * @generated from field: bool hostname = 13; + */ + value: boolean; + case: "hostname"; + } + | { + /** + * `ip` specifies that the field value must be a valid IP (v4 or v6) address. + * + * IPv4 addresses are expected in the dotted decimal format—for example, "192.168.5.21". + * IPv6 addresses are expected in their text representation—for example, "::1", + * or "2001:0DB8:ABCD:0012::0". + * + * Both formats are well-defined in the internet standard [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). + * Zone identifiers for IPv6 addresses (for example, "fe80::a%en1") are supported. + * + * If the field value isn't a valid IP address, an error message will be + * generated. + * + * ```proto + * message MyString { + * // value must be a valid IP address + * string value = 1 [(buf.validate.field).string.ip = true]; + * } + * ``` + * + * @generated from field: bool ip = 14; + */ + value: boolean; + case: "ip"; + } + | { + /** + * `ipv4` specifies that the field value must be a valid IPv4 address—for + * example "192.168.5.21". If the field value isn't a valid IPv4 address, an + * error message will be generated. + * + * ```proto + * message MyString { + * // value must be a valid IPv4 address + * string value = 1 [(buf.validate.field).string.ipv4 = true]; + * } + * ``` + * + * @generated from field: bool ipv4 = 15; + */ + value: boolean; + case: "ipv4"; + } + | { + /** + * `ipv6` specifies that the field value must be a valid IPv6 address—for + * example "::1", or "d7a:115c:a1e0:ab12:4843:cd96:626b:430b". If the field + * value is not a valid IPv6 address, an error message will be generated. + * + * ```proto + * message MyString { + * // value must be a valid IPv6 address + * string value = 1 [(buf.validate.field).string.ipv6 = true]; + * } + * ``` + * + * @generated from field: bool ipv6 = 16; + */ + value: boolean; + case: "ipv6"; + } + | { + /** + * `uri` specifies that the field value must be a valid URI, for example + * "https://example.com/foo/bar?baz=quux#frag". + * + * URI is defined in the internet standard [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). + * Zone Identifiers in IPv6 address literals are supported ([RFC 6874](https://datatracker.ietf.org/doc/html/rfc6874)). + * + * If the field value isn't a valid URI, an error message will be generated. + * + * ```proto + * message MyString { + * // value must be a valid URI + * string value = 1 [(buf.validate.field).string.uri = true]; + * } + * ``` + * + * @generated from field: bool uri = 17; + */ + value: boolean; + case: "uri"; + } + | { + /** + * `uri_ref` specifies that the field value must be a valid URI Reference—either + * a URI such as "https://example.com/foo/bar?baz=quux#frag", or a Relative + * Reference such as "./foo/bar?query". + * + * URI, URI Reference, and Relative Reference are defined in the internet + * standard [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). Zone + * Identifiers in IPv6 address literals are supported ([RFC 6874](https://datatracker.ietf.org/doc/html/rfc6874)). + * + * If the field value isn't a valid URI Reference, an error message will be + * generated. + * + * ```proto + * message MyString { + * // value must be a valid URI Reference + * string value = 1 [(buf.validate.field).string.uri_ref = true]; + * } + * ``` + * + * @generated from field: bool uri_ref = 18; + */ + value: boolean; + case: "uriRef"; + } + | { + /** + * `address` specifies that the field value must be either a valid hostname + * (for example, "example.com"), or a valid IP (v4 or v6) address (for example, + * "192.168.0.1", or "::1"). If the field value isn't a valid hostname or IP, + * an error message will be generated. + * + * ```proto + * message MyString { + * // value must be a valid hostname, or ip address + * string value = 1 [(buf.validate.field).string.address = true]; + * } + * ``` + * + * @generated from field: bool address = 21; + */ + value: boolean; + case: "address"; + } + | { + /** + * `uuid` specifies that the field value must be a valid UUID as defined by + * [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122#section-4.1.2). If the + * field value isn't a valid UUID, an error message will be generated. + * + * ```proto + * message MyString { + * // value must be a valid UUID + * string value = 1 [(buf.validate.field).string.uuid = true]; + * } + * ``` + * + * @generated from field: bool uuid = 22; + */ + value: boolean; + case: "uuid"; + } + | { + /** + * `tuuid` (trimmed UUID) specifies that the field value must be a valid UUID as + * defined by [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122#section-4.1.2) with all dashes + * omitted. If the field value isn't a valid UUID without dashes, an error message + * will be generated. + * + * ```proto + * message MyString { + * // value must be a valid trimmed UUID + * string value = 1 [(buf.validate.field).string.tuuid = true]; + * } + * ``` + * + * @generated from field: bool tuuid = 33; + */ + value: boolean; + case: "tuuid"; + } + | { + /** + * `ip_with_prefixlen` specifies that the field value must be a valid IP + * (v4 or v6) address with prefix length—for example, "192.168.5.21/16" or + * "2001:0DB8:ABCD:0012::F1/64". If the field value isn't a valid IP with + * prefix length, an error message will be generated. + * + * ```proto + * message MyString { + * // value must be a valid IP with prefix length + * string value = 1 [(buf.validate.field).string.ip_with_prefixlen = true]; + * } + * ``` + * + * @generated from field: bool ip_with_prefixlen = 26; + */ + value: boolean; + case: "ipWithPrefixlen"; + } + | { + /** + * `ipv4_with_prefixlen` specifies that the field value must be a valid + * IPv4 address with prefix length—for example, "192.168.5.21/16". If the + * field value isn't a valid IPv4 address with prefix length, an error + * message will be generated. + * + * ```proto + * message MyString { + * // value must be a valid IPv4 address with prefix length + * string value = 1 [(buf.validate.field).string.ipv4_with_prefixlen = true]; + * } + * ``` + * + * @generated from field: bool ipv4_with_prefixlen = 27; + */ + value: boolean; + case: "ipv4WithPrefixlen"; + } + | { + /** + * `ipv6_with_prefixlen` specifies that the field value must be a valid + * IPv6 address with prefix length—for example, "2001:0DB8:ABCD:0012::F1/64". + * If the field value is not a valid IPv6 address with prefix length, + * an error message will be generated. + * + * ```proto + * message MyString { + * // value must be a valid IPv6 address prefix length + * string value = 1 [(buf.validate.field).string.ipv6_with_prefixlen = true]; + * } + * ``` + * + * @generated from field: bool ipv6_with_prefixlen = 28; + */ + value: boolean; + case: "ipv6WithPrefixlen"; + } + | { + /** + * `ip_prefix` specifies that the field value must be a valid IP (v4 or v6) + * prefix—for example, "192.168.0.0/16" or "2001:0DB8:ABCD:0012::0/64". + * + * The prefix must have all zeros for the unmasked bits. For example, + * "2001:0DB8:ABCD:0012::0/64" designates the left-most 64 bits for the + * prefix, and the remaining 64 bits must be zero. + * + * If the field value isn't a valid IP prefix, an error message will be + * generated. + * + * ```proto + * message MyString { + * // value must be a valid IP prefix + * string value = 1 [(buf.validate.field).string.ip_prefix = true]; + * } + * ``` + * + * @generated from field: bool ip_prefix = 29; + */ + value: boolean; + case: "ipPrefix"; + } + | { + /** + * `ipv4_prefix` specifies that the field value must be a valid IPv4 + * prefix, for example "192.168.0.0/16". + * + * The prefix must have all zeros for the unmasked bits. For example, + * "192.168.0.0/16" designates the left-most 16 bits for the prefix, + * and the remaining 16 bits must be zero. + * + * If the field value isn't a valid IPv4 prefix, an error message + * will be generated. + * + * ```proto + * message MyString { + * // value must be a valid IPv4 prefix + * string value = 1 [(buf.validate.field).string.ipv4_prefix = true]; + * } + * ``` + * + * @generated from field: bool ipv4_prefix = 30; + */ + value: boolean; + case: "ipv4Prefix"; + } + | { + /** + * `ipv6_prefix` specifies that the field value must be a valid IPv6 prefix—for + * example, "2001:0DB8:ABCD:0012::0/64". + * + * The prefix must have all zeros for the unmasked bits. For example, + * "2001:0DB8:ABCD:0012::0/64" designates the left-most 64 bits for the + * prefix, and the remaining 64 bits must be zero. + * + * If the field value is not a valid IPv6 prefix, an error message will be + * generated. + * + * ```proto + * message MyString { + * // value must be a valid IPv6 prefix + * string value = 1 [(buf.validate.field).string.ipv6_prefix = true]; + * } + * ``` + * + * @generated from field: bool ipv6_prefix = 31; + */ + value: boolean; + case: "ipv6Prefix"; + } + | { + /** + * `host_and_port` specifies that the field value must be valid host/port + * pair—for example, "example.com:8080". + * + * The host can be one of: + * - An IPv4 address in dotted decimal format—for example, "192.168.5.21". + * - An IPv6 address enclosed in square brackets—for example, "[2001:0DB8:ABCD:0012::F1]". + * - A hostname—for example, "example.com". + * + * The port is separated by a colon. It must be non-empty, with a decimal number + * in the range of 0-65535, inclusive. + * + * @generated from field: bool host_and_port = 32; + */ + value: boolean; + case: "hostAndPort"; + } + | { + /** + * `well_known_regex` specifies a common well-known pattern + * defined as a regex. If the field value doesn't match the well-known + * regex, an error message will be generated. + * + * ```proto + * message MyString { + * // value must be a valid HTTP header value + * string value = 1 [(buf.validate.field).string.well_known_regex = KNOWN_REGEX_HTTP_HEADER_VALUE]; + * } + * ``` + * + * #### KnownRegex + * + * `well_known_regex` contains some well-known patterns. + * + * | Name | Number | Description | + * |-------------------------------|--------|-------------------------------------------| + * | KNOWN_REGEX_UNSPECIFIED | 0 | | + * | KNOWN_REGEX_HTTP_HEADER_NAME | 1 | HTTP header name as defined by [RFC 7230](https://datatracker.ietf.org/doc/html/rfc7230#section-3.2) | + * | KNOWN_REGEX_HTTP_HEADER_VALUE | 2 | HTTP header value as defined by [RFC 7230](https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.4) | + * + * @generated from field: buf.validate.KnownRegex well_known_regex = 24; + */ + value: KnownRegex; + case: "wellKnownRegex"; + } + | { case: undefined; value?: undefined }; + + /** + * This applies to regexes `HTTP_HEADER_NAME` and `HTTP_HEADER_VALUE` to + * enable strict header validation. By default, this is true, and HTTP header + * validations are [RFC-compliant](https://datatracker.ietf.org/doc/html/rfc7230#section-3). Setting to false will enable looser + * validations that only disallow `\r\n\0` characters, which can be used to + * bypass header matching rules. + * + * ```proto + * message MyString { + * // The field `value` must have be a valid HTTP headers, but not enforced with strict rules. + * string value = 1 [(buf.validate.field).string.strict = false]; + * } + * ``` + * + * @generated from field: optional bool strict = 25; + */ + strict: boolean; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyString { + * string value = 1 [ + * (buf.validate.field).string.example = "hello", + * (buf.validate.field).string.example = "world" + * ]; + * } + * ``` + * + * @generated from field: repeated string example = 34; + */ + example: string[]; +}; + +/** + * Describes the message buf.validate.StringRules. + * Use `create(StringRulesSchema)` to create a new message. + */ +export const StringRulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 18); + +/** + * BytesRules describe the rules applied to `bytes` values. These rules + * may also be applied to the `google.protobuf.BytesValue` Well-Known-Type. + * + * @generated from message buf.validate.BytesRules + */ +export type BytesRules = Message<"buf.validate.BytesRules"> & { + /** + * `const` requires the field value to exactly match the specified bytes + * value. If the field value doesn't match, an error message is generated. + * + * ```proto + * message MyBytes { + * // value must be "\x01\x02\x03\x04" + * bytes value = 1 [(buf.validate.field).bytes.const = "\x01\x02\x03\x04"]; + * } + * ``` + * + * @generated from field: optional bytes const = 1; + */ + const: Uint8Array; + + /** + * `len` requires the field value to have the specified length in bytes. + * If the field value doesn't match, an error message is generated. + * + * ```proto + * message MyBytes { + * // value length must be 4 bytes. + * optional bytes value = 1 [(buf.validate.field).bytes.len = 4]; + * } + * ``` + * + * @generated from field: optional uint64 len = 13; + */ + len: bigint; + + /** + * `min_len` requires the field value to have at least the specified minimum + * length in bytes. + * If the field value doesn't meet the requirement, an error message is generated. + * + * ```proto + * message MyBytes { + * // value length must be at least 2 bytes. + * optional bytes value = 1 [(buf.validate.field).bytes.min_len = 2]; + * } + * ``` + * + * @generated from field: optional uint64 min_len = 2; + */ + minLen: bigint; + + /** + * `max_len` requires the field value to have at most the specified maximum + * length in bytes. + * If the field value exceeds the requirement, an error message is generated. + * + * ```proto + * message MyBytes { + * // value must be at most 6 bytes. + * optional bytes value = 1 [(buf.validate.field).bytes.max_len = 6]; + * } + * ``` + * + * @generated from field: optional uint64 max_len = 3; + */ + maxLen: bigint; + + /** + * `pattern` requires the field value to match the specified regular + * expression ([RE2 syntax](https://github.com/google/re2/wiki/Syntax)). + * The value of the field must be valid UTF-8 or validation will fail with a + * runtime error. + * If the field value doesn't match the pattern, an error message is generated. + * + * ```proto + * message MyBytes { + * // value must match regex pattern "^[a-zA-Z0-9]+$". + * optional bytes value = 1 [(buf.validate.field).bytes.pattern = "^[a-zA-Z0-9]+$"]; + * } + * ``` + * + * @generated from field: optional string pattern = 4; + */ + pattern: string; + + /** + * `prefix` requires the field value to have the specified bytes at the + * beginning of the string. + * If the field value doesn't meet the requirement, an error message is generated. + * + * ```proto + * message MyBytes { + * // value does not have prefix \x01\x02 + * optional bytes value = 1 [(buf.validate.field).bytes.prefix = "\x01\x02"]; + * } + * ``` + * + * @generated from field: optional bytes prefix = 5; + */ + prefix: Uint8Array; + + /** + * `suffix` requires the field value to have the specified bytes at the end + * of the string. + * If the field value doesn't meet the requirement, an error message is generated. + * + * ```proto + * message MyBytes { + * // value does not have suffix \x03\x04 + * optional bytes value = 1 [(buf.validate.field).bytes.suffix = "\x03\x04"]; + * } + * ``` + * + * @generated from field: optional bytes suffix = 6; + */ + suffix: Uint8Array; + + /** + * `contains` requires the field value to have the specified bytes anywhere in + * the string. + * If the field value doesn't meet the requirement, an error message is generated. + * + * ```protobuf + * message MyBytes { + * // value does not contain \x02\x03 + * optional bytes value = 1 [(buf.validate.field).bytes.contains = "\x02\x03"]; + * } + * ``` + * + * @generated from field: optional bytes contains = 7; + */ + contains: Uint8Array; + + /** + * `in` requires the field value to be equal to one of the specified + * values. If the field value doesn't match any of the specified values, an + * error message is generated. + * + * ```protobuf + * message MyBytes { + * // value must in ["\x01\x02", "\x02\x03", "\x03\x04"] + * optional bytes value = 1 [(buf.validate.field).bytes.in = {"\x01\x02", "\x02\x03", "\x03\x04"}]; + * } + * ``` + * + * @generated from field: repeated bytes in = 8; + */ + in: Uint8Array[]; + + /** + * `not_in` requires the field value to be not equal to any of the specified + * values. + * If the field value matches any of the specified values, an error message is + * generated. + * + * ```proto + * message MyBytes { + * // value must not in ["\x01\x02", "\x02\x03", "\x03\x04"] + * optional bytes value = 1 [(buf.validate.field).bytes.not_in = {"\x01\x02", "\x02\x03", "\x03\x04"}]; + * } + * ``` + * + * @generated from field: repeated bytes not_in = 9; + */ + notIn: Uint8Array[]; + + /** + * WellKnown rules provide advanced rules against common byte + * patterns + * + * @generated from oneof buf.validate.BytesRules.well_known + */ + wellKnown: + | { + /** + * `ip` ensures that the field `value` is a valid IP address (v4 or v6) in byte format. + * If the field value doesn't meet this rule, an error message is generated. + * + * ```proto + * message MyBytes { + * // value must be a valid IP address + * optional bytes value = 1 [(buf.validate.field).bytes.ip = true]; + * } + * ``` + * + * @generated from field: bool ip = 10; + */ + value: boolean; + case: "ip"; + } + | { + /** + * `ipv4` ensures that the field `value` is a valid IPv4 address in byte format. + * If the field value doesn't meet this rule, an error message is generated. + * + * ```proto + * message MyBytes { + * // value must be a valid IPv4 address + * optional bytes value = 1 [(buf.validate.field).bytes.ipv4 = true]; + * } + * ``` + * + * @generated from field: bool ipv4 = 11; + */ + value: boolean; + case: "ipv4"; + } + | { + /** + * `ipv6` ensures that the field `value` is a valid IPv6 address in byte format. + * If the field value doesn't meet this rule, an error message is generated. + * ```proto + * message MyBytes { + * // value must be a valid IPv6 address + * optional bytes value = 1 [(buf.validate.field).bytes.ipv6 = true]; + * } + * ``` + * + * @generated from field: bool ipv6 = 12; + */ + value: boolean; + case: "ipv6"; + } + | { case: undefined; value?: undefined }; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyBytes { + * bytes value = 1 [ + * (buf.validate.field).bytes.example = "\x01\x02", + * (buf.validate.field).bytes.example = "\x02\x03" + * ]; + * } + * ``` + * + * @generated from field: repeated bytes example = 14; + */ + example: Uint8Array[]; +}; + +/** + * Describes the message buf.validate.BytesRules. + * Use `create(BytesRulesSchema)` to create a new message. + */ +export const BytesRulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 19); + +/** + * EnumRules describe the rules applied to `enum` values. + * + * @generated from message buf.validate.EnumRules + */ +export type EnumRules = Message<"buf.validate.EnumRules"> & { + /** + * `const` requires the field value to exactly match the specified enum value. + * If the field value doesn't match, an error message is generated. + * + * ```proto + * enum MyEnum { + * MY_ENUM_UNSPECIFIED = 0; + * MY_ENUM_VALUE1 = 1; + * MY_ENUM_VALUE2 = 2; + * } + * + * message MyMessage { + * // The field `value` must be exactly MY_ENUM_VALUE1. + * MyEnum value = 1 [(buf.validate.field).enum.const = 1]; + * } + * ``` + * + * @generated from field: optional int32 const = 1; + */ + const: number; + + /** + * `defined_only` requires the field value to be one of the defined values for + * this enum, failing on any undefined value. + * + * ```proto + * enum MyEnum { + * MY_ENUM_UNSPECIFIED = 0; + * MY_ENUM_VALUE1 = 1; + * MY_ENUM_VALUE2 = 2; + * } + * + * message MyMessage { + * // The field `value` must be a defined value of MyEnum. + * MyEnum value = 1 [(buf.validate.field).enum.defined_only = true]; + * } + * ``` + * + * @generated from field: optional bool defined_only = 2; + */ + definedOnly: boolean; + + /** + * `in` requires the field value to be equal to one of the + * specified enum values. If the field value doesn't match any of the + * specified values, an error message is generated. + * + * ```proto + * enum MyEnum { + * MY_ENUM_UNSPECIFIED = 0; + * MY_ENUM_VALUE1 = 1; + * MY_ENUM_VALUE2 = 2; + * } + * + * message MyMessage { + * // The field `value` must be equal to one of the specified values. + * MyEnum value = 1 [(buf.validate.field).enum = { in: [1, 2]}]; + * } + * ``` + * + * @generated from field: repeated int32 in = 3; + */ + in: number[]; + + /** + * `not_in` requires the field value to be not equal to any of the + * specified enum values. If the field value matches one of the specified + * values, an error message is generated. + * + * ```proto + * enum MyEnum { + * MY_ENUM_UNSPECIFIED = 0; + * MY_ENUM_VALUE1 = 1; + * MY_ENUM_VALUE2 = 2; + * } + * + * message MyMessage { + * // The field `value` must not be equal to any of the specified values. + * MyEnum value = 1 [(buf.validate.field).enum = { not_in: [1, 2]}]; + * } + * ``` + * + * @generated from field: repeated int32 not_in = 4; + */ + notIn: number[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * enum MyEnum { + * MY_ENUM_UNSPECIFIED = 0; + * MY_ENUM_VALUE1 = 1; + * MY_ENUM_VALUE2 = 2; + * } + * + * message MyMessage { + * (buf.validate.field).enum.example = 1, + * (buf.validate.field).enum.example = 2 + * } + * ``` + * + * @generated from field: repeated int32 example = 5; + */ + example: number[]; +}; + +/** + * Describes the message buf.validate.EnumRules. + * Use `create(EnumRulesSchema)` to create a new message. + */ +export const EnumRulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 20); + +/** + * RepeatedRules describe the rules applied to `repeated` values. + * + * @generated from message buf.validate.RepeatedRules + */ +export type RepeatedRules = Message<"buf.validate.RepeatedRules"> & { + /** + * `min_items` requires that this field must contain at least the specified + * minimum number of items. + * + * Note that `min_items = 1` is equivalent to setting a field as `required`. + * + * ```proto + * message MyRepeated { + * // value must contain at least 2 items + * repeated string value = 1 [(buf.validate.field).repeated.min_items = 2]; + * } + * ``` + * + * @generated from field: optional uint64 min_items = 1; + */ + minItems: bigint; + + /** + * `max_items` denotes that this field must not exceed a + * certain number of items as the upper limit. If the field contains more + * items than specified, an error message will be generated, requiring the + * field to maintain no more than the specified number of items. + * + * ```proto + * message MyRepeated { + * // value must contain no more than 3 item(s) + * repeated string value = 1 [(buf.validate.field).repeated.max_items = 3]; + * } + * ``` + * + * @generated from field: optional uint64 max_items = 2; + */ + maxItems: bigint; + + /** + * `unique` indicates that all elements in this field must + * be unique. This rule is strictly applicable to scalar and enum + * types, with message types not being supported. + * + * ```proto + * message MyRepeated { + * // repeated value must contain unique items + * repeated string value = 1 [(buf.validate.field).repeated.unique = true]; + * } + * ``` + * + * @generated from field: optional bool unique = 3; + */ + unique: boolean; + + /** + * `items` details the rules to be applied to each item + * in the field. Even for repeated message fields, validation is executed + * against each item unless skip is explicitly specified. + * + * ```proto + * message MyRepeated { + * // The items in the field `value` must follow the specified rules. + * repeated string value = 1 [(buf.validate.field).repeated.items = { + * string: { + * min_len: 3 + * max_len: 10 + * } + * }]; + * } + * ``` + * + * @generated from field: optional buf.validate.FieldRules items = 4; + */ + items?: FieldRules; +}; + +/** + * Describes the message buf.validate.RepeatedRules. + * Use `create(RepeatedRulesSchema)` to create a new message. + */ +export const RepeatedRulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 21); + +/** + * MapRules describe the rules applied to `map` values. + * + * @generated from message buf.validate.MapRules + */ +export type MapRules = Message<"buf.validate.MapRules"> & { + /** + * Specifies the minimum number of key-value pairs allowed. If the field has + * fewer key-value pairs than specified, an error message is generated. + * + * ```proto + * message MyMap { + * // The field `value` must have at least 2 key-value pairs. + * map value = 1 [(buf.validate.field).map.min_pairs = 2]; + * } + * ``` + * + * @generated from field: optional uint64 min_pairs = 1; + */ + minPairs: bigint; + + /** + * Specifies the maximum number of key-value pairs allowed. If the field has + * more key-value pairs than specified, an error message is generated. + * + * ```proto + * message MyMap { + * // The field `value` must have at most 3 key-value pairs. + * map value = 1 [(buf.validate.field).map.max_pairs = 3]; + * } + * ``` + * + * @generated from field: optional uint64 max_pairs = 2; + */ + maxPairs: bigint; + + /** + * Specifies the rules to be applied to each key in the field. + * + * ```proto + * message MyMap { + * // The keys in the field `value` must follow the specified rules. + * map value = 1 [(buf.validate.field).map.keys = { + * string: { + * min_len: 3 + * max_len: 10 + * } + * }]; + * } + * ``` + * + * @generated from field: optional buf.validate.FieldRules keys = 4; + */ + keys?: FieldRules; + + /** + * Specifies the rules to be applied to the value of each key in the + * field. Message values will still have their validations evaluated unless + * skip is specified here. + * + * ```proto + * message MyMap { + * // The values in the field `value` must follow the specified rules. + * map value = 1 [(buf.validate.field).map.values = { + * string: { + * min_len: 5 + * max_len: 20 + * } + * }]; + * } + * ``` + * + * @generated from field: optional buf.validate.FieldRules values = 5; + */ + values?: FieldRules; +}; + +/** + * Describes the message buf.validate.MapRules. + * Use `create(MapRulesSchema)` to create a new message. + */ +export const MapRulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 22); + +/** + * AnyRules describe rules applied exclusively to the `google.protobuf.Any` well-known type. + * + * @generated from message buf.validate.AnyRules + */ +export type AnyRules = Message<"buf.validate.AnyRules"> & { + /** + * `in` requires the field's `type_url` to be equal to one of the + * specified values. If it doesn't match any of the specified values, an error + * message is generated. + * + * ```proto + * message MyAny { + * // The `value` field must have a `type_url` equal to one of the specified values. + * google.protobuf.Any value = 1 [(buf.validate.field).any.in = ["type.googleapis.com/MyType1", "type.googleapis.com/MyType2"]]; + * } + * ``` + * + * @generated from field: repeated string in = 2; + */ + in: string[]; + + /** + * requires the field's type_url to be not equal to any of the specified values. If it matches any of the specified values, an error message is generated. + * + * ```proto + * message MyAny { + * // The field `value` must not have a `type_url` equal to any of the specified values. + * google.protobuf.Any value = 1 [(buf.validate.field).any.not_in = ["type.googleapis.com/ForbiddenType1", "type.googleapis.com/ForbiddenType2"]]; + * } + * ``` + * + * @generated from field: repeated string not_in = 3; + */ + notIn: string[]; +}; + +/** + * Describes the message buf.validate.AnyRules. + * Use `create(AnyRulesSchema)` to create a new message. + */ +export const AnyRulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 23); + +/** + * DurationRules describe the rules applied exclusively to the `google.protobuf.Duration` well-known type. + * + * @generated from message buf.validate.DurationRules + */ +export type DurationRules = Message<"buf.validate.DurationRules"> & { + /** + * `const` dictates that the field must match the specified value of the `google.protobuf.Duration` type exactly. + * If the field's value deviates from the specified value, an error message + * will be generated. + * + * ```proto + * message MyDuration { + * // value must equal 5s + * google.protobuf.Duration value = 1 [(buf.validate.field).duration.const = "5s"]; + * } + * ``` + * + * @generated from field: optional google.protobuf.Duration const = 2; + */ + const?: Duration; + + /** + * @generated from oneof buf.validate.DurationRules.less_than + */ + lessThan: + | { + /** + * `lt` stipulates that the field must be less than the specified value of the `google.protobuf.Duration` type, + * exclusive. If the field's value is greater than or equal to the specified + * value, an error message will be generated. + * + * ```proto + * message MyDuration { + * // value must be less than 5s + * google.protobuf.Duration value = 1 [(buf.validate.field).duration.lt = "5s"]; + * } + * ``` + * + * @generated from field: google.protobuf.Duration lt = 3; + */ + value: Duration; + case: "lt"; + } + | { + /** + * `lte` indicates that the field must be less than or equal to the specified + * value of the `google.protobuf.Duration` type, inclusive. If the field's value is greater than the specified value, + * an error message will be generated. + * + * ```proto + * message MyDuration { + * // value must be less than or equal to 10s + * google.protobuf.Duration value = 1 [(buf.validate.field).duration.lte = "10s"]; + * } + * ``` + * + * @generated from field: google.protobuf.Duration lte = 4; + */ + value: Duration; + case: "lte"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.DurationRules.greater_than + */ + greaterThan: + | { + /** + * `gt` requires the duration field value to be greater than the specified + * value (exclusive). If the value of `gt` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyDuration { + * // duration must be greater than 5s [duration.gt] + * google.protobuf.Duration value = 1 [(buf.validate.field).duration.gt = { seconds: 5 }]; + * + * // duration must be greater than 5s and less than 10s [duration.gt_lt] + * google.protobuf.Duration another_value = 2 [(buf.validate.field).duration = { gt: { seconds: 5 }, lt: { seconds: 10 } }]; + * + * // duration must be greater than 10s or less than 5s [duration.gt_lt_exclusive] + * google.protobuf.Duration other_value = 3 [(buf.validate.field).duration = { gt: { seconds: 10 }, lt: { seconds: 5 } }]; + * } + * ``` + * + * @generated from field: google.protobuf.Duration gt = 5; + */ + value: Duration; + case: "gt"; + } + | { + /** + * `gte` requires the duration field value to be greater than or equal to the + * specified value (exclusive). If the value of `gte` is larger than a + * specified `lt` or `lte`, the range is reversed, and the field value must + * be outside the specified range. If the field value doesn't meet the + * required conditions, an error message is generated. + * + * ```proto + * message MyDuration { + * // duration must be greater than or equal to 5s [duration.gte] + * google.protobuf.Duration value = 1 [(buf.validate.field).duration.gte = { seconds: 5 }]; + * + * // duration must be greater than or equal to 5s and less than 10s [duration.gte_lt] + * google.protobuf.Duration another_value = 2 [(buf.validate.field).duration = { gte: { seconds: 5 }, lt: { seconds: 10 } }]; + * + * // duration must be greater than or equal to 10s or less than 5s [duration.gte_lt_exclusive] + * google.protobuf.Duration other_value = 3 [(buf.validate.field).duration = { gte: { seconds: 10 }, lt: { seconds: 5 } }]; + * } + * ``` + * + * @generated from field: google.protobuf.Duration gte = 6; + */ + value: Duration; + case: "gte"; + } + | { case: undefined; value?: undefined }; + + /** + * `in` asserts that the field must be equal to one of the specified values of the `google.protobuf.Duration` type. + * If the field's value doesn't correspond to any of the specified values, + * an error message will be generated. + * + * ```proto + * message MyDuration { + * // value must be in list [1s, 2s, 3s] + * google.protobuf.Duration value = 1 [(buf.validate.field).duration.in = ["1s", "2s", "3s"]]; + * } + * ``` + * + * @generated from field: repeated google.protobuf.Duration in = 7; + */ + in: Duration[]; + + /** + * `not_in` denotes that the field must not be equal to + * any of the specified values of the `google.protobuf.Duration` type. + * If the field's value matches any of these values, an error message will be + * generated. + * + * ```proto + * message MyDuration { + * // value must not be in list [1s, 2s, 3s] + * google.protobuf.Duration value = 1 [(buf.validate.field).duration.not_in = ["1s", "2s", "3s"]]; + * } + * ``` + * + * @generated from field: repeated google.protobuf.Duration not_in = 8; + */ + notIn: Duration[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyDuration { + * google.protobuf.Duration value = 1 [ + * (buf.validate.field).duration.example = { seconds: 1 }, + * (buf.validate.field).duration.example = { seconds: 2 }, + * ]; + * } + * ``` + * + * @generated from field: repeated google.protobuf.Duration example = 9; + */ + example: Duration[]; +}; + +/** + * Describes the message buf.validate.DurationRules. + * Use `create(DurationRulesSchema)` to create a new message. + */ +export const DurationRulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 24); + +/** + * TimestampRules describe the rules applied exclusively to the `google.protobuf.Timestamp` well-known type. + * + * @generated from message buf.validate.TimestampRules + */ +export type TimestampRules = Message<"buf.validate.TimestampRules"> & { + /** + * `const` dictates that this field, of the `google.protobuf.Timestamp` type, must exactly match the specified value. If the field value doesn't correspond to the specified timestamp, an error message will be generated. + * + * ```proto + * message MyTimestamp { + * // value must equal 2023-05-03T10:00:00Z + * google.protobuf.Timestamp created_at = 1 [(buf.validate.field).timestamp.const = {seconds: 1727998800}]; + * } + * ``` + * + * @generated from field: optional google.protobuf.Timestamp const = 2; + */ + const?: Timestamp; + + /** + * @generated from oneof buf.validate.TimestampRules.less_than + */ + lessThan: + | { + /** + * requires the duration field value to be less than the specified value (field < value). If the field value doesn't meet the required conditions, an error message is generated. + * + * ```proto + * message MyDuration { + * // duration must be less than 'P3D' [duration.lt] + * google.protobuf.Duration value = 1 [(buf.validate.field).duration.lt = { seconds: 259200 }]; + * } + * ``` + * + * @generated from field: google.protobuf.Timestamp lt = 3; + */ + value: Timestamp; + case: "lt"; + } + | { + /** + * requires the timestamp field value to be less than or equal to the specified value (field <= value). If the field value doesn't meet the required conditions, an error message is generated. + * + * ```proto + * message MyTimestamp { + * // timestamp must be less than or equal to '2023-05-14T00:00:00Z' [timestamp.lte] + * google.protobuf.Timestamp value = 1 [(buf.validate.field).timestamp.lte = { seconds: 1678867200 }]; + * } + * ``` + * + * @generated from field: google.protobuf.Timestamp lte = 4; + */ + value: Timestamp; + case: "lte"; + } + | { + /** + * `lt_now` specifies that this field, of the `google.protobuf.Timestamp` type, must be less than the current time. `lt_now` can only be used with the `within` rule. + * + * ```proto + * message MyTimestamp { + * // value must be less than now + * google.protobuf.Timestamp created_at = 1 [(buf.validate.field).timestamp.lt_now = true]; + * } + * ``` + * + * @generated from field: bool lt_now = 7; + */ + value: boolean; + case: "ltNow"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.TimestampRules.greater_than + */ + greaterThan: + | { + /** + * `gt` requires the timestamp field value to be greater than the specified + * value (exclusive). If the value of `gt` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyTimestamp { + * // timestamp must be greater than '2023-01-01T00:00:00Z' [timestamp.gt] + * google.protobuf.Timestamp value = 1 [(buf.validate.field).timestamp.gt = { seconds: 1672444800 }]; + * + * // timestamp must be greater than '2023-01-01T00:00:00Z' and less than '2023-01-02T00:00:00Z' [timestamp.gt_lt] + * google.protobuf.Timestamp another_value = 2 [(buf.validate.field).timestamp = { gt: { seconds: 1672444800 }, lt: { seconds: 1672531200 } }]; + * + * // timestamp must be greater than '2023-01-02T00:00:00Z' or less than '2023-01-01T00:00:00Z' [timestamp.gt_lt_exclusive] + * google.protobuf.Timestamp other_value = 3 [(buf.validate.field).timestamp = { gt: { seconds: 1672531200 }, lt: { seconds: 1672444800 } }]; + * } + * ``` + * + * @generated from field: google.protobuf.Timestamp gt = 5; + */ + value: Timestamp; + case: "gt"; + } + | { + /** + * `gte` requires the timestamp field value to be greater than or equal to the + * specified value (exclusive). If the value of `gte` is larger than a + * specified `lt` or `lte`, the range is reversed, and the field value + * must be outside the specified range. If the field value doesn't meet + * the required conditions, an error message is generated. + * + * ```proto + * message MyTimestamp { + * // timestamp must be greater than or equal to '2023-01-01T00:00:00Z' [timestamp.gte] + * google.protobuf.Timestamp value = 1 [(buf.validate.field).timestamp.gte = { seconds: 1672444800 }]; + * + * // timestamp must be greater than or equal to '2023-01-01T00:00:00Z' and less than '2023-01-02T00:00:00Z' [timestamp.gte_lt] + * google.protobuf.Timestamp another_value = 2 [(buf.validate.field).timestamp = { gte: { seconds: 1672444800 }, lt: { seconds: 1672531200 } }]; + * + * // timestamp must be greater than or equal to '2023-01-02T00:00:00Z' or less than '2023-01-01T00:00:00Z' [timestamp.gte_lt_exclusive] + * google.protobuf.Timestamp other_value = 3 [(buf.validate.field).timestamp = { gte: { seconds: 1672531200 }, lt: { seconds: 1672444800 } }]; + * } + * ``` + * + * @generated from field: google.protobuf.Timestamp gte = 6; + */ + value: Timestamp; + case: "gte"; + } + | { + /** + * `gt_now` specifies that this field, of the `google.protobuf.Timestamp` type, must be greater than the current time. `gt_now` can only be used with the `within` rule. + * + * ```proto + * message MyTimestamp { + * // value must be greater than now + * google.protobuf.Timestamp created_at = 1 [(buf.validate.field).timestamp.gt_now = true]; + * } + * ``` + * + * @generated from field: bool gt_now = 8; + */ + value: boolean; + case: "gtNow"; + } + | { case: undefined; value?: undefined }; + + /** + * `within` specifies that this field, of the `google.protobuf.Timestamp` type, must be within the specified duration of the current time. If the field value isn't within the duration, an error message is generated. + * + * ```proto + * message MyTimestamp { + * // value must be within 1 hour of now + * google.protobuf.Timestamp created_at = 1 [(buf.validate.field).timestamp.within = {seconds: 3600}]; + * } + * ``` + * + * @generated from field: optional google.protobuf.Duration within = 9; + */ + within?: Duration; + + /** + * @generated from field: repeated google.protobuf.Timestamp example = 10; + */ + example: Timestamp[]; +}; + +/** + * Describes the message buf.validate.TimestampRules. + * Use `create(TimestampRulesSchema)` to create a new message. + */ +export const TimestampRulesSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 25); + +/** + * `Violations` is a collection of `Violation` messages. This message type is returned by + * protovalidate when a proto message fails to meet the requirements set by the `Rule` validation rules. + * Each individual violation is represented by a `Violation` message. + * + * @generated from message buf.validate.Violations + */ +export type Violations = Message<"buf.validate.Violations"> & { + /** + * `violations` is a repeated field that contains all the `Violation` messages corresponding to the violations detected. + * + * @generated from field: repeated buf.validate.Violation violations = 1; + */ + violations: Violation[]; +}; + +/** + * Describes the message buf.validate.Violations. + * Use `create(ViolationsSchema)` to create a new message. + */ +export const ViolationsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 26); + +/** + * `Violation` represents a single instance where a validation rule, expressed + * as a `Rule`, was not met. It provides information about the field that + * caused the violation, the specific rule that wasn't fulfilled, and a + * human-readable error message. + * + * ```json + * { + * "fieldPath": "bar", + * "ruleId": "foo.bar", + * "message": "bar must be greater than 0" + * } + * ``` + * + * @generated from message buf.validate.Violation + */ +export type Violation = Message<"buf.validate.Violation"> & { + /** + * `field` is a machine-readable path to the field that failed validation. + * This could be a nested field, in which case the path will include all the parent fields leading to the actual field that caused the violation. + * + * For example, consider the following message: + * + * ```proto + * message Message { + * bool a = 1 [(buf.validate.field).required = true]; + * } + * ``` + * + * It could produce the following violation: + * + * ```textproto + * violation { + * field { element { field_number: 1, field_name: "a", field_type: 8 } } + * ... + * } + * ``` + * + * @generated from field: optional buf.validate.FieldPath field = 5; + */ + field?: FieldPath; + + /** + * `rule` is a machine-readable path that points to the specific rule rule that failed validation. + * This will be a nested field starting from the FieldRules of the field that failed validation. + * For custom rules, this will provide the path of the rule, e.g. `cel[0]`. + * + * For example, consider the following message: + * + * ```proto + * message Message { + * bool a = 1 [(buf.validate.field).required = true]; + * bool b = 2 [(buf.validate.field).cel = { + * id: "custom_rule", + * expression: "!this ? 'b must be true': ''" + * }] + * } + * ``` + * + * It could produce the following violations: + * + * ```textproto + * violation { + * rule { element { field_number: 25, field_name: "required", field_type: 8 } } + * ... + * } + * violation { + * rule { element { field_number: 23, field_name: "cel", field_type: 11, index: 0 } } + * ... + * } + * ``` + * + * @generated from field: optional buf.validate.FieldPath rule = 6; + */ + rule?: FieldPath; + + /** + * `rule_id` is the unique identifier of the `Rule` that was not fulfilled. + * This is the same `id` that was specified in the `Rule` message, allowing easy tracing of which rule was violated. + * + * @generated from field: optional string rule_id = 2; + */ + ruleId: string; + + /** + * `message` is a human-readable error message that describes the nature of the violation. + * This can be the default error message from the violated `Rule`, or it can be a custom message that gives more context about the violation. + * + * @generated from field: optional string message = 3; + */ + message: string; + + /** + * `for_key` indicates whether the violation was caused by a map key, rather than a value. + * + * @generated from field: optional bool for_key = 4; + */ + forKey: boolean; +}; + +/** + * Describes the message buf.validate.Violation. + * Use `create(ViolationSchema)` to create a new message. + */ +export const ViolationSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 27); + +/** + * `FieldPath` provides a path to a nested protobuf field. + * + * This message provides enough information to render a dotted field path even without protobuf descriptors. + * It also provides enough information to resolve a nested field through unknown wire data. + * + * @generated from message buf.validate.FieldPath + */ +export type FieldPath = Message<"buf.validate.FieldPath"> & { + /** + * `elements` contains each element of the path, starting from the root and recursing downward. + * + * @generated from field: repeated buf.validate.FieldPathElement elements = 1; + */ + elements: FieldPathElement[]; +}; + +/** + * Describes the message buf.validate.FieldPath. + * Use `create(FieldPathSchema)` to create a new message. + */ +export const FieldPathSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 28); + +/** + * `FieldPathElement` provides enough information to nest through a single protobuf field. + * + * If the selected field is a map or repeated field, the `subscript` value selects a specific element from it. + * A path that refers to a value nested under a map key or repeated field index will have a `subscript` value. + * The `field_type` field allows unambiguous resolution of a field even if descriptors are not available. + * + * @generated from message buf.validate.FieldPathElement + */ +export type FieldPathElement = Message<"buf.validate.FieldPathElement"> & { + /** + * `field_number` is the field number this path element refers to. + * + * @generated from field: optional int32 field_number = 1; + */ + fieldNumber: number; + + /** + * `field_name` contains the field name this path element refers to. + * This can be used to display a human-readable path even if the field number is unknown. + * + * @generated from field: optional string field_name = 2; + */ + fieldName: string; + + /** + * `field_type` specifies the type of this field. When using reflection, this value is not needed. + * + * This value is provided to make it possible to traverse unknown fields through wire data. + * When traversing wire data, be mindful of both packed[1] and delimited[2] encoding schemes. + * + * [1]: https://protobuf.dev/programming-guides/encoding/#packed + * [2]: https://protobuf.dev/programming-guides/encoding/#groups + * + * N.B.: Although groups are deprecated, the corresponding delimited encoding scheme is not, and + * can be explicitly used in Protocol Buffers 2023 Edition. + * + * @generated from field: optional google.protobuf.FieldDescriptorProto.Type field_type = 3; + */ + fieldType: FieldDescriptorProto_Type; + + /** + * `key_type` specifies the map key type of this field. This value is useful when traversing + * unknown fields through wire data: specifically, it allows handling the differences between + * different integer encodings. + * + * @generated from field: optional google.protobuf.FieldDescriptorProto.Type key_type = 4; + */ + keyType: FieldDescriptorProto_Type; + + /** + * `value_type` specifies map value type of this field. This is useful if you want to display a + * value inside unknown fields through wire data. + * + * @generated from field: optional google.protobuf.FieldDescriptorProto.Type value_type = 5; + */ + valueType: FieldDescriptorProto_Type; + + /** + * `subscript` contains a repeated index or map key, if this path element nests into a repeated or map field. + * + * @generated from oneof buf.validate.FieldPathElement.subscript + */ + subscript: + | { + /** + * `index` specifies a 0-based index into a repeated field. + * + * @generated from field: uint64 index = 6; + */ + value: bigint; + case: "index"; + } + | { + /** + * `bool_key` specifies a map key of type bool. + * + * @generated from field: bool bool_key = 7; + */ + value: boolean; + case: "boolKey"; + } + | { + /** + * `int_key` specifies a map key of type int32, int64, sint32, sint64, sfixed32 or sfixed64. + * + * @generated from field: int64 int_key = 8; + */ + value: bigint; + case: "intKey"; + } + | { + /** + * `uint_key` specifies a map key of type uint32, uint64, fixed32 or fixed64. + * + * @generated from field: uint64 uint_key = 9; + */ + value: bigint; + case: "uintKey"; + } + | { + /** + * `string_key` specifies a map key of type string. + * + * @generated from field: string string_key = 10; + */ + value: string; + case: "stringKey"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message buf.validate.FieldPathElement. + * Use `create(FieldPathElementSchema)` to create a new message. + */ +export const FieldPathElementSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 29); + +/** + * Specifies how FieldRules.ignore behaves. See the documentation for + * FieldRules.required for definitions of "populated" and "nullable". + * + * @generated from enum buf.validate.Ignore + */ +export enum Ignore { + /** + * Validation is only skipped if it's an unpopulated nullable fields. + * + * ```proto + * syntax="proto3"; + * + * message Request { + * // The uri rule applies to any value, including the empty string. + * string foo = 1 [ + * (buf.validate.field).string.uri = true + * ]; + * + * // The uri rule only applies if the field is set, including if it's + * // set to the empty string. + * optional string bar = 2 [ + * (buf.validate.field).string.uri = true + * ]; + * + * // The min_items rule always applies, even if the list is empty. + * repeated string baz = 3 [ + * (buf.validate.field).repeated.min_items = 3 + * ]; + * + * // The custom CEL rule applies only if the field is set, including if + * // it's the "zero" value of that message. + * SomeMessage quux = 4 [ + * (buf.validate.field).cel = {/* ... *\/} + * ]; + * } + * ``` + * + * @generated from enum value: IGNORE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Validation is skipped if the field is unpopulated. This rule is redundant + * if the field is already nullable. + * + * ```proto + * syntax="proto3 + * + * message Request { + * // The uri rule applies only if the value is not the empty string. + * string foo = 1 [ + * (buf.validate.field).string.uri = true, + * (buf.validate.field).ignore = IGNORE_IF_UNPOPULATED + * ]; + * + * // IGNORE_IF_UNPOPULATED is equivalent to IGNORE_UNSPECIFIED in this + * // case: the uri rule only applies if the field is set, including if + * // it's set to the empty string. + * optional string bar = 2 [ + * (buf.validate.field).string.uri = true, + * (buf.validate.field).ignore = IGNORE_IF_UNPOPULATED + * ]; + * + * // The min_items rule only applies if the list has at least one item. + * repeated string baz = 3 [ + * (buf.validate.field).repeated.min_items = 3, + * (buf.validate.field).ignore = IGNORE_IF_UNPOPULATED + * ]; + * + * // IGNORE_IF_UNPOPULATED is equivalent to IGNORE_UNSPECIFIED in this + * // case: the custom CEL rule applies only if the field is set, including + * // if it's the "zero" value of that message. + * SomeMessage quux = 4 [ + * (buf.validate.field).cel = {/* ... *\/}, + * (buf.validate.field).ignore = IGNORE_IF_UNPOPULATED + * ]; + * } + * ``` + * + * @generated from enum value: IGNORE_IF_UNPOPULATED = 1; + */ + IF_UNPOPULATED = 1, + + /** + * Validation is skipped if the field is unpopulated or if it is a nullable + * field populated with its default value. This is typically the zero or + * empty value, but proto2 scalars support custom defaults. For messages, the + * default is a non-null message with all its fields unpopulated. + * + * ```proto + * syntax="proto3 + * + * message Request { + * // IGNORE_IF_DEFAULT_VALUE is equivalent to IGNORE_IF_UNPOPULATED in + * // this case; the uri rule applies only if the value is not the empty + * // string. + * string foo = 1 [ + * (buf.validate.field).string.uri = true, + * (buf.validate.field).ignore = IGNORE_IF_DEFAULT_VALUE + * ]; + * + * // The uri rule only applies if the field is set to a value other than + * // the empty string. + * optional string bar = 2 [ + * (buf.validate.field).string.uri = true, + * (buf.validate.field).ignore = IGNORE_IF_DEFAULT_VALUE + * ]; + * + * // IGNORE_IF_DEFAULT_VALUE is equivalent to IGNORE_IF_UNPOPULATED in + * // this case; the min_items rule only applies if the list has at least + * // one item. + * repeated string baz = 3 [ + * (buf.validate.field).repeated.min_items = 3, + * (buf.validate.field).ignore = IGNORE_IF_DEFAULT_VALUE + * ]; + * + * // The custom CEL rule only applies if the field is set to a value other + * // than an empty message (i.e., fields are unpopulated). + * SomeMessage quux = 4 [ + * (buf.validate.field).cel = {/* ... *\/}, + * (buf.validate.field).ignore = IGNORE_IF_DEFAULT_VALUE + * ]; + * } + * ``` + * + * This rule is affected by proto2 custom default values: + * + * ```proto + * syntax="proto2"; + * + * message Request { + * // The gt rule only applies if the field is set and it's value is not + * the default (i.e., not -42). The rule even applies if the field is set + * to zero since the default value differs. + * optional int32 value = 1 [ + * default = -42, + * (buf.validate.field).int32.gt = 0, + * (buf.validate.field).ignore = IGNORE_IF_DEFAULT_VALUE + * ]; + * } + * + * @generated from enum value: IGNORE_IF_DEFAULT_VALUE = 2; + */ + IF_DEFAULT_VALUE = 2, + + /** + * The validation rules of this field will be skipped and not evaluated. This + * is useful for situations that necessitate turning off the rules of a field + * containing a message that may not make sense in the current context, or to + * temporarily disable rules during development. + * + * ```proto + * message MyMessage { + * // The field's rules will always be ignored, including any validation's + * // on value's fields. + * MyOtherMessage value = 1 [ + * (buf.validate.field).ignore = IGNORE_ALWAYS]; + * } + * ``` + * + * @generated from enum value: IGNORE_ALWAYS = 3; + */ + ALWAYS = 3, +} + +/** + * Describes the enum buf.validate.Ignore. + */ +export const IgnoreSchema: GenEnum = /*@__PURE__*/ enumDesc(file_buf_validate_validate, 0); + +/** + * WellKnownRegex contain some well-known patterns. + * + * @generated from enum buf.validate.KnownRegex + */ +export enum KnownRegex { + /** + * @generated from enum value: KNOWN_REGEX_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * HTTP header name as defined by [RFC 7230](https://datatracker.ietf.org/doc/html/rfc7230#section-3.2). + * + * @generated from enum value: KNOWN_REGEX_HTTP_HEADER_NAME = 1; + */ + HTTP_HEADER_NAME = 1, + + /** + * HTTP header value as defined by [RFC 7230](https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.4). + * + * @generated from enum value: KNOWN_REGEX_HTTP_HEADER_VALUE = 2; + */ + HTTP_HEADER_VALUE = 2, +} + +/** + * Describes the enum buf.validate.KnownRegex. + */ +export const KnownRegexSchema: GenEnum = /*@__PURE__*/ enumDesc(file_buf_validate_validate, 1); + +/** + * Rules specify the validations to be performed on this message. By default, + * no validation is performed against a message. + * + * @generated from extension: optional buf.validate.MessageRules message = 1159; + */ +export const message: GenExtension = /*@__PURE__*/ extDesc(file_buf_validate_validate, 0); + +/** + * Rules specify the validations to be performed on this oneof. By default, + * no validation is performed against a oneof. + * + * @generated from extension: optional buf.validate.OneofRules oneof = 1159; + */ +export const oneof: GenExtension = /*@__PURE__*/ extDesc(file_buf_validate_validate, 1); + +/** + * Rules specify the validations to be performed on this field. By default, + * no validation is performed against a field. + * + * @generated from extension: optional buf.validate.FieldRules field = 1159; + */ +export const field: GenExtension = /*@__PURE__*/ extDesc(file_buf_validate_validate, 2); + +/** + * Specifies predefined rules. When extending a standard rule message, + * this adds additional CEL expressions that apply when the extension is used. + * + * ```proto + * extend buf.validate.Int32Rules { + * bool is_zero [(buf.validate.predefined).cel = { + * id: "int32.is_zero", + * message: "value must be zero", + * expression: "!rule || this == 0", + * }]; + * } + * + * message Foo { + * int32 reserved = 1 [(buf.validate.field).int32.(is_zero) = true]; + * } + * ``` + * + * @generated from extension: optional buf.validate.PredefinedRules predefined = 1160; + */ +export const predefined: GenExtension = + /*@__PURE__*/ + extDesc(file_buf_validate_validate, 3); diff --git a/client/src/protoFleet/api/generated/capabilities/v1/capabilities_pb.ts b/client/src/protoFleet/api/generated/capabilities/v1/capabilities_pb.ts new file mode 100644 index 000000000..bbcc28a57 --- /dev/null +++ b/client/src/protoFleet/api/generated/capabilities/v1/capabilities_pb.ts @@ -0,0 +1,335 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file capabilities/v1/capabilities.proto (package capabilities.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_buf_validate_validate } from "../../buf/validate/validate_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file capabilities/v1/capabilities.proto. + */ +export const file_capabilities_v1_capabilities: GenFile = + /*@__PURE__*/ + fileDesc( + "CiJjYXBhYmlsaXRpZXMvdjEvY2FwYWJpbGl0aWVzLnByb3RvEg9jYXBhYmlsaXRpZXMudjEixQIKEU1pbmVyQ2FwYWJpbGl0aWVzEh8KDG1hbnVmYWN0dXJlchgBIAEoCUIJukgGcgQQARhkEksKDmF1dGhlbnRpY2F0aW9uGAIgASgLMisuY2FwYWJpbGl0aWVzLnYxLkF1dGhlbnRpY2F0aW9uQ2FwYWJpbGl0aWVzQga6SAPIAQESPgoIY29tbWFuZHMYAyABKAsyJC5jYXBhYmlsaXRpZXMudjEuQ29tbWFuZENhcGFiaWxpdGllc0IGukgDyAEBEkEKCXRlbGVtZXRyeRgEIAEoCzImLmNhcGFiaWxpdGllcy52MS5UZWxlbWV0cnlDYXBhYmlsaXRpZXNCBrpIA8gBARI/CghmaXJtd2FyZRgFIAEoCzIlLmNhcGFiaWxpdGllcy52MS5GaXJtd2FyZUNhcGFiaWxpdGllc0IGukgDyAEBIm8KGkF1dGhlbnRpY2F0aW9uQ2FwYWJpbGl0aWVzElEKEXN1cHBvcnRlZF9tZXRob2RzGAEgAygOMiUuY2FwYWJpbGl0aWVzLnYxLkF1dGhlbnRpY2F0aW9uTWV0aG9kQg+6SAySAQkIASIFggECIAAivgMKE0NvbW1hbmRDYXBhYmlsaXRpZXMSGAoQcmVib290X3N1cHBvcnRlZBgBIAEoCBIeChZtaW5pbmdfc3RhcnRfc3VwcG9ydGVkGAIgASgIEh0KFW1pbmluZ19zdG9wX3N1cHBvcnRlZBgDIAEoCBIbChNsZWRfYmxpbmtfc3VwcG9ydGVkGAQgASgIEh8KF2ZhY3RvcnlfcmVzZXRfc3VwcG9ydGVkGAUgASgIEh0KFWFpcl9jb29saW5nX3N1cHBvcnRlZBgGIAEoCBIjChtpbW1lcnNpb25fY29vbGluZ19zdXBwb3J0ZWQYByABKAgSIAoYcG9vbF9zd2l0Y2hpbmdfc3VwcG9ydGVkGAggASgIEhYKDnBvb2xfbWF4X2NvdW50GAkgASgFEh8KF3Bvb2xfcHJpb3JpdHlfc3VwcG9ydGVkGAogASgIEh8KF2xvZ3NfZG93bmxvYWRfc3VwcG9ydGVkGAsgASgIEicKH3Bvd2VyX21vZGVfZWZmaWNpZW5jeV9zdXBwb3J0ZWQYDCABKAgSJwofdXBkYXRlX21pbmVyX3Bhc3N3b3JkX3N1cHBvcnRlZBgNIAEoCCLCAwoVVGVsZW1ldHJ5Q2FwYWJpbGl0aWVzEiQKHHJlYWx0aW1lX3RlbGVtZXRyeV9zdXBwb3J0ZWQYASABKAgSIQoZaGlzdG9yaWNhbF9kYXRhX3N1cHBvcnRlZBgCIAEoCBIZChFoYXNocmF0ZV9yZXBvcnRlZBgDIAEoCBIcChRwb3dlcl91c2FnZV9yZXBvcnRlZBgEIAEoCBIcChR0ZW1wZXJhdHVyZV9yZXBvcnRlZBgFIAEoCBIaChJmYW5fc3BlZWRfcmVwb3J0ZWQYBiABKAgSGwoTZWZmaWNpZW5jeV9yZXBvcnRlZBgHIAEoCBIXCg91cHRpbWVfcmVwb3J0ZWQYCCABKAgSHAoUZXJyb3JfY291bnRfcmVwb3J0ZWQYCSABKAgSHQoVbWluZXJfc3RhdHVzX3JlcG9ydGVkGAogASgIEhsKE3Bvb2xfc3RhdHNfcmVwb3J0ZWQYCyABKAgSHwoXcGVyX2NoaXBfc3RhdHNfcmVwb3J0ZWQYDCABKAgSIAoYcGVyX2JvYXJkX3N0YXRzX3JlcG9ydGVkGA0gASgIEhoKEnBzdV9zdGF0c19yZXBvcnRlZBgOIAEoCCJVChRGaXJtd2FyZUNhcGFiaWxpdGllcxIcChRvdGFfdXBkYXRlX3N1cHBvcnRlZBgBIAEoCBIfChdtYW51YWxfdXBsb2FkX3N1cHBvcnRlZBgCIAEoCCqIAQoUQXV0aGVudGljYXRpb25NZXRob2QSJQohQVVUSEVOVElDQVRJT05fTUVUSE9EX1VOU1BFQ0lGSUVEEAASHwobQVVUSEVOVElDQVRJT05fTUVUSE9EX0JBU0lDEAESKAokQVVUSEVOVElDQVRJT05fTUVUSE9EX0FTWU1NRVRSSUNfS0VZEAJC2AEKE2NvbS5jYXBhYmlsaXRpZXMudjFCEUNhcGFiaWxpdGllc1Byb3RvUAFaUWdpdGh1Yi5jb20vYmxvY2svcHJvdG8tZmxlZXQvc2VydmVyL2dlbmVyYXRlZC9ncnBjL2NhcGFiaWxpdGllcy92MTtjYXBhYmlsaXRpZXN2MaICA0NYWKoCD0NhcGFiaWxpdGllcy5WMcoCD0NhcGFiaWxpdGllc1xWMeICG0NhcGFiaWxpdGllc1xWMVxHUEJNZXRhZGF0YeoCEENhcGFiaWxpdGllczo6VjFiBnByb3RvMw", + [file_buf_validate_validate], + ); + +/** + * MinerCapabilities defines what operations and features a specific miner model supports + * + * @generated from message capabilities.v1.MinerCapabilities + */ +export type MinerCapabilities = Message<"capabilities.v1.MinerCapabilities"> & { + /** + * Manufacturer name + * + * @generated from field: string manufacturer = 1; + */ + manufacturer: string; + + /** + * Authentication capabilities + * + * @generated from field: capabilities.v1.AuthenticationCapabilities authentication = 2; + */ + authentication?: AuthenticationCapabilities; + + /** + * Command capabilities + * + * @generated from field: capabilities.v1.CommandCapabilities commands = 3; + */ + commands?: CommandCapabilities; + + /** + * Telemetry capabilities + * + * @generated from field: capabilities.v1.TelemetryCapabilities telemetry = 4; + */ + telemetry?: TelemetryCapabilities; + + /** + * Firmware capabilities + * + * @generated from field: capabilities.v1.FirmwareCapabilities firmware = 5; + */ + firmware?: FirmwareCapabilities; +}; + +/** + * Describes the message capabilities.v1.MinerCapabilities. + * Use `create(MinerCapabilitiesSchema)` to create a new message. + */ +export const MinerCapabilitiesSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_capabilities_v1_capabilities, 0); + +/** + * Authentication methods supported by the miner + * + * @generated from message capabilities.v1.AuthenticationCapabilities + */ +export type AuthenticationCapabilities = Message<"capabilities.v1.AuthenticationCapabilities"> & { + /** + * Authentication methods supported by the miner + * + * @generated from field: repeated capabilities.v1.AuthenticationMethod supported_methods = 1; + */ + supportedMethods: AuthenticationMethod[]; +}; + +/** + * Describes the message capabilities.v1.AuthenticationCapabilities. + * Use `create(AuthenticationCapabilitiesSchema)` to create a new message. + */ +export const AuthenticationCapabilitiesSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_capabilities_v1_capabilities, 1); + +/** + * Command operations supported by the miner + * + * @generated from message capabilities.v1.CommandCapabilities + */ +export type CommandCapabilities = Message<"capabilities.v1.CommandCapabilities"> & { + /** + * Basic operations + * + * @generated from field: bool reboot_supported = 1; + */ + rebootSupported: boolean; + + /** + * @generated from field: bool mining_start_supported = 2; + */ + miningStartSupported: boolean; + + /** + * @generated from field: bool mining_stop_supported = 3; + */ + miningStopSupported: boolean; + + /** + * LED operations + * + * @generated from field: bool led_blink_supported = 4; + */ + ledBlinkSupported: boolean; + + /** + * Advanced operations + * + * @generated from field: bool factory_reset_supported = 5; + */ + factoryResetSupported: boolean; + + /** + * Cooling control + * + * @generated from field: bool air_cooling_supported = 6; + */ + airCoolingSupported: boolean; + + /** + * @generated from field: bool immersion_cooling_supported = 7; + */ + immersionCoolingSupported: boolean; + + /** + * Pool management + * + * @generated from field: bool pool_switching_supported = 8; + */ + poolSwitchingSupported: boolean; + + /** + * @generated from field: int32 pool_max_count = 9; + */ + poolMaxCount: number; + + /** + * @generated from field: bool pool_priority_supported = 10; + */ + poolPrioritySupported: boolean; + + /** + * Log management + * + * @generated from field: bool logs_download_supported = 11; + */ + logsDownloadSupported: boolean; + + /** + * Power mode control + * + * @generated from field: bool power_mode_efficiency_supported = 12; + */ + powerModeEfficiencySupported: boolean; + + /** + * Security management + * + * @generated from field: bool update_miner_password_supported = 13; + */ + updateMinerPasswordSupported: boolean; +}; + +/** + * Describes the message capabilities.v1.CommandCapabilities. + * Use `create(CommandCapabilitiesSchema)` to create a new message. + */ +export const CommandCapabilitiesSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_capabilities_v1_capabilities, 2); + +/** + * Telemetry data capabilities + * + * @generated from message capabilities.v1.TelemetryCapabilities + */ +export type TelemetryCapabilities = Message<"capabilities.v1.TelemetryCapabilities"> & { + /** + * Real-time telemetry + * + * @generated from field: bool realtime_telemetry_supported = 1; + */ + realtimeTelemetrySupported: boolean; + + /** + * Historical data + * + * @generated from field: bool historical_data_supported = 2; + */ + historicalDataSupported: boolean; + + /** + * Supported metrics + * + * @generated from field: bool hashrate_reported = 3; + */ + hashrateReported: boolean; + + /** + * @generated from field: bool power_usage_reported = 4; + */ + powerUsageReported: boolean; + + /** + * @generated from field: bool temperature_reported = 5; + */ + temperatureReported: boolean; + + /** + * @generated from field: bool fan_speed_reported = 6; + */ + fanSpeedReported: boolean; + + /** + * @generated from field: bool efficiency_reported = 7; + */ + efficiencyReported: boolean; + + /** + * @generated from field: bool uptime_reported = 8; + */ + uptimeReported: boolean; + + /** + * @generated from field: bool error_count_reported = 9; + */ + errorCountReported: boolean; + + /** + * @generated from field: bool miner_status_reported = 10; + */ + minerStatusReported: boolean; + + /** + * @generated from field: bool pool_stats_reported = 11; + */ + poolStatsReported: boolean; + + /** + * Component-level telemetry + * + * @generated from field: bool per_chip_stats_reported = 12; + */ + perChipStatsReported: boolean; + + /** + * @generated from field: bool per_board_stats_reported = 13; + */ + perBoardStatsReported: boolean; + + /** + * @generated from field: bool psu_stats_reported = 14; + */ + psuStatsReported: boolean; +}; + +/** + * Describes the message capabilities.v1.TelemetryCapabilities. + * Use `create(TelemetryCapabilitiesSchema)` to create a new message. + */ +export const TelemetryCapabilitiesSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_capabilities_v1_capabilities, 3); + +/** + * Firmware update capabilities + * + * @generated from message capabilities.v1.FirmwareCapabilities + */ +export type FirmwareCapabilities = Message<"capabilities.v1.FirmwareCapabilities"> & { + /** + * Update methods + * + * @generated from field: bool ota_update_supported = 1; + */ + otaUpdateSupported: boolean; + + /** + * @generated from field: bool manual_upload_supported = 2; + */ + manualUploadSupported: boolean; +}; + +/** + * Describes the message capabilities.v1.FirmwareCapabilities. + * Use `create(FirmwareCapabilitiesSchema)` to create a new message. + */ +export const FirmwareCapabilitiesSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_capabilities_v1_capabilities, 4); + +/** + * Authentication method types + * + * @generated from enum capabilities.v1.AuthenticationMethod + */ +export enum AuthenticationMethod { + /** + * @generated from enum value: AUTHENTICATION_METHOD_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: AUTHENTICATION_METHOD_BASIC = 1; + */ + BASIC = 1, + + /** + * @generated from enum value: AUTHENTICATION_METHOD_ASYMMETRIC_KEY = 2; + */ + ASYMMETRIC_KEY = 2, +} + +/** + * Describes the enum capabilities.v1.AuthenticationMethod. + */ +export const AuthenticationMethodSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_capabilities_v1_capabilities, 0); diff --git a/client/src/protoFleet/api/generated/collection/v1/collection_pb.ts b/client/src/protoFleet/api/generated/collection/v1/collection_pb.ts new file mode 100644 index 000000000..4eaf522de --- /dev/null +++ b/client/src/protoFleet/api/generated/collection/v1/collection_pb.ts @@ -0,0 +1,1771 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file collection/v1/collection.proto (package collection.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import { file_buf_validate_validate } from "../../buf/validate/validate_pb"; +import type { DeviceSelector } from "../../common/v1/device_selector_pb"; +import { file_common_v1_device_selector } from "../../common/v1/device_selector_pb"; +import type { SortConfig } from "../../common/v1/sort_pb"; +import { file_common_v1_sort } from "../../common/v1/sort_pb"; +import type { ComponentType } from "../../errors/v1/errors_pb"; +import { file_errors_v1_errors } from "../../errors/v1/errors_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file collection/v1/collection.proto. + */ +export const file_collection_v1_collection: GenFile = + /*@__PURE__*/ + fileDesc( + "Ch5jb2xsZWN0aW9uL3YxL2NvbGxlY3Rpb24ucHJvdG8SDWNvbGxlY3Rpb24udjEi0wIKEERldmljZUNvbGxlY3Rpb24SCgoCaWQYASABKAMSKwoEdHlwZRgCIAEoDjIdLmNvbGxlY3Rpb24udjEuQ29sbGVjdGlvblR5cGUSDQoFbGFiZWwYAyABKAkSEwoLZGVzY3JpcHRpb24YBCABKAkSFAoMZGV2aWNlX2NvdW50GAUgASgFEi4KCmNyZWF0ZWRfYXQYBiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCnVwZGF0ZWRfYXQYByABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEiwKCXJhY2tfaW5mbxgIIAEoCzIXLmNvbGxlY3Rpb24udjEuUmFja0luZm9IABIuCgpncm91cF9pbmZvGAkgASgLMhguY29sbGVjdGlvbi52MS5Hcm91cEluZm9IAEIOCgx0eXBlX2RldGFpbHMivAEKCFJhY2tJbmZvEhUKBHJvd3MYASABKAVCB7pIBBoCIAASGAoHY29sdW1ucxgCIAEoBUIHukgEGgIgABIVCgR6b25lGAMgASgJQge6SARyAhABEjIKC29yZGVyX2luZGV4GAQgASgOMh0uY29sbGVjdGlvbi52MS5SYWNrT3JkZXJJbmRleBI0Cgxjb29saW5nX3R5cGUYBSABKA4yHi5jb2xsZWN0aW9uLnYxLlJhY2tDb29saW5nVHlwZSILCglHcm91cEluZm8inwEKEENvbGxlY3Rpb25NZW1iZXISGQoRZGV2aWNlX2lkZW50aWZpZXIYASABKAkSLAoIYWRkZWRfYXQYAiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjAKBHJhY2sYAyABKAsyIC5jb2xsZWN0aW9uLnYxLlJhY2tNZW1iZXJEZXRhaWxzSABCEAoObWVtYmVyX2RldGFpbHMiSwoRUmFja01lbWJlckRldGFpbHMSNgoNc2xvdF9wb3NpdGlvbhgBIAEoCzIfLmNvbGxlY3Rpb24udjEuUmFja1Nsb3RQb3NpdGlvbiJBChBSYWNrU2xvdFBvc2l0aW9uEhQKA3JvdxgBIAEoBUIHukgEGgIoABIXCgZjb2x1bW4YAiABKAVCB7pIBBoCKAAiyQIKF0NyZWF0ZUNvbGxlY3Rpb25SZXF1ZXN0EjcKBHR5cGUYASABKA4yHS5jb2xsZWN0aW9uLnYxLkNvbGxlY3Rpb25UeXBlQgq6SAeCAQQQASAAEhsKBWxhYmVsGAIgASgJQgy6SAnIAQFyBBABGGQSHQoLZGVzY3JpcHRpb24YAyABKAlCCLpIBXIDGPQDEiwKCXJhY2tfaW5mbxgEIAEoCzIXLmNvbGxlY3Rpb24udjEuUmFja0luZm9IABIuCgpncm91cF9pbmZvGAUgASgLMhguY29sbGVjdGlvbi52MS5Hcm91cEluZm9IABI3Cg9kZXZpY2Vfc2VsZWN0b3IYBiABKAsyGS5jb21tb24udjEuRGV2aWNlU2VsZWN0b3JIAYgBAUIOCgx0eXBlX2RldGFpbHNCEgoQX2RldmljZV9zZWxlY3RvciJkChhDcmVhdGVDb2xsZWN0aW9uUmVzcG9uc2USMwoKY29sbGVjdGlvbhgBIAEoCzIfLmNvbGxlY3Rpb24udjEuRGV2aWNlQ29sbGVjdGlvbhITCgthZGRlZF9jb3VudBgCIAEoBSI2ChRHZXRDb2xsZWN0aW9uUmVxdWVzdBIeCg1jb2xsZWN0aW9uX2lkGAEgASgDQge6SAQiAiAAIkwKFUdldENvbGxlY3Rpb25SZXNwb25zZRIzCgpjb2xsZWN0aW9uGAEgASgLMh8uY29sbGVjdGlvbi52MS5EZXZpY2VDb2xsZWN0aW9uIr4CChdVcGRhdGVDb2xsZWN0aW9uUmVxdWVzdBIeCg1jb2xsZWN0aW9uX2lkGAEgASgDQge6SAQiAiAAEiAKBWxhYmVsGAIgASgJQgy6SAnYAQFyBBABGGRIAYgBARIlCgtkZXNjcmlwdGlvbhgDIAEoCUILukgI2AEBcgMY9ANIAogBARIsCglyYWNrX2luZm8YBCABKAsyFy5jb2xsZWN0aW9uLnYxLlJhY2tJbmZvSAASLgoKZ3JvdXBfaW5mbxgFIAEoCzIYLmNvbGxlY3Rpb24udjEuR3JvdXBJbmZvSAASMgoPZGV2aWNlX3NlbGVjdG9yGAYgASgLMhkuY29tbW9uLnYxLkRldmljZVNlbGVjdG9yQg4KDHR5cGVfZGV0YWlsc0IICgZfbGFiZWxCDgoMX2Rlc2NyaXB0aW9uIk8KGFVwZGF0ZUNvbGxlY3Rpb25SZXNwb25zZRIzCgpjb2xsZWN0aW9uGAEgASgLMh8uY29sbGVjdGlvbi52MS5EZXZpY2VDb2xsZWN0aW9uIjkKF0RlbGV0ZUNvbGxlY3Rpb25SZXF1ZXN0Eh4KDWNvbGxlY3Rpb25faWQYASABKANCB7pIBCICIAAiGgoYRGVsZXRlQ29sbGVjdGlvblJlc3BvbnNlIuwBChZMaXN0Q29sbGVjdGlvbnNSZXF1ZXN0EjUKBHR5cGUYASABKA4yHS5jb2xsZWN0aW9uLnYxLkNvbGxlY3Rpb25UeXBlQgi6SAWCAQIQARIaCglwYWdlX3NpemUYAiABKAVCB7pIBBoCKAASEgoKcGFnZV90b2tlbhgDIAEoCRIjCgRzb3J0GAQgASgLMhUuY29tbW9uLnYxLlNvcnRDb25maWcSNwoVZXJyb3JfY29tcG9uZW50X3R5cGVzGAUgAygOMhguZXJyb3JzLnYxLkNvbXBvbmVudFR5cGUSDQoFem9uZXMYBiADKAkifQoXTGlzdENvbGxlY3Rpb25zUmVzcG9uc2USNAoLY29sbGVjdGlvbnMYASADKAsyHy5jb2xsZWN0aW9uLnYxLkRldmljZUNvbGxlY3Rpb24SFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJEhMKC3RvdGFsX2NvdW50GAMgASgFInsKHUFkZERldmljZXNUb0NvbGxlY3Rpb25SZXF1ZXN0Eh4KDWNvbGxlY3Rpb25faWQYASABKANCB7pIBCICIAASOgoPZGV2aWNlX3NlbGVjdG9yGAIgASgLMhkuY29tbW9uLnYxLkRldmljZVNlbGVjdG9yQga6SAPIAQEiTAoeQWRkRGV2aWNlc1RvQ29sbGVjdGlvblJlc3BvbnNlEhUKDWNvbGxlY3Rpb25faWQYASABKAMSEwoLYWRkZWRfY291bnQYAiABKAUigAEKIlJlbW92ZURldmljZXNGcm9tQ29sbGVjdGlvblJlcXVlc3QSHgoNY29sbGVjdGlvbl9pZBgBIAEoA0IHukgEIgIgABI6Cg9kZXZpY2Vfc2VsZWN0b3IYAiABKAsyGS5jb21tb24udjEuRGV2aWNlU2VsZWN0b3JCBrpIA8gBASI8CiNSZW1vdmVEZXZpY2VzRnJvbUNvbGxlY3Rpb25SZXNwb25zZRIVCg1yZW1vdmVkX2NvdW50GAEgASgFIm4KHExpc3RDb2xsZWN0aW9uTWVtYmVyc1JlcXVlc3QSHgoNY29sbGVjdGlvbl9pZBgBIAEoA0IHukgEIgIgABIaCglwYWdlX3NpemUYAiABKAVCB7pIBBoCKAASEgoKcGFnZV90b2tlbhgDIAEoCSJqCh1MaXN0Q29sbGVjdGlvbk1lbWJlcnNSZXNwb25zZRIwCgdtZW1iZXJzGAEgAygLMh8uY29sbGVjdGlvbi52MS5Db2xsZWN0aW9uTWVtYmVyEhcKD25leHRfcGFnZV90b2tlbhgCIAEoCSJuChtHZXREZXZpY2VDb2xsZWN0aW9uc1JlcXVlc3QSIgoRZGV2aWNlX2lkZW50aWZpZXIYASABKAlCB7pIBHICEAESKwoEdHlwZRgCIAEoDjIdLmNvbGxlY3Rpb24udjEuQ29sbGVjdGlvblR5cGUiVAocR2V0RGV2aWNlQ29sbGVjdGlvbnNSZXNwb25zZRI0Cgtjb2xsZWN0aW9ucxgBIAMoCzIfLmNvbGxlY3Rpb24udjEuRGV2aWNlQ29sbGVjdGlvbiKbAQoaU2V0UmFja1Nsb3RQb3NpdGlvblJlcXVlc3QSHgoNY29sbGVjdGlvbl9pZBgBIAEoA0IHukgEIgIgABIiChFkZXZpY2VfaWRlbnRpZmllchgCIAEoCUIHukgEcgIQARI5Cghwb3NpdGlvbhgDIAEoCzIfLmNvbGxlY3Rpb24udjEuUmFja1Nsb3RQb3NpdGlvbkIGukgDyAEBIlsKG1NldFJhY2tTbG90UG9zaXRpb25SZXNwb25zZRIVCg1jb2xsZWN0aW9uX2lkGAEgASgDEiUKBHNsb3QYAiABKAsyFy5jb2xsZWN0aW9uLnYxLlJhY2tTbG90ImIKHENsZWFyUmFja1Nsb3RQb3NpdGlvblJlcXVlc3QSHgoNY29sbGVjdGlvbl9pZBgBIAEoA0IHukgEIgIgABIiChFkZXZpY2VfaWRlbnRpZmllchgCIAEoCUIHukgEcgIQASIfCh1DbGVhclJhY2tTbG90UG9zaXRpb25SZXNwb25zZSI1ChNHZXRSYWNrU2xvdHNSZXF1ZXN0Eh4KDWNvbGxlY3Rpb25faWQYASABKANCB7pIBCICIAAiWAoIUmFja1Nsb3QSGQoRZGV2aWNlX2lkZW50aWZpZXIYASABKAkSMQoIcG9zaXRpb24YAiABKAsyHy5jb2xsZWN0aW9uLnYxLlJhY2tTbG90UG9zaXRpb24iPgoUR2V0UmFja1Nsb3RzUmVzcG9uc2USJgoFc2xvdHMYASADKAsyFy5jb2xsZWN0aW9uLnYxLlJhY2tTbG90Iu4ECg9Db2xsZWN0aW9uU3RhdHMSFQoNY29sbGVjdGlvbl9pZBgBIAEoAxIUCgxkZXZpY2VfY291bnQYAiABKAUSFwoPcmVwb3J0aW5nX2NvdW50GAMgASgFEhoKEnRvdGFsX2hhc2hyYXRlX3RocxgEIAEoARIaChJhdmdfZWZmaWNpZW5jeV9qdGgYBSABKAESFgoOdG90YWxfcG93ZXJfa3cYBiABKAESGQoRbWluX3RlbXBlcmF0dXJlX2MYByABKAESGQoRbWF4X3RlbXBlcmF0dXJlX2MYCCABKAESFQoNaGFzaGluZ19jb3VudBgJIAEoBRIUCgxicm9rZW5fY291bnQYCiABKAUSFQoNb2ZmbGluZV9jb3VudBgLIAEoBRIWCg5zbGVlcGluZ19jb3VudBgMIAEoBRIgChhoYXNocmF0ZV9yZXBvcnRpbmdfY291bnQYDSABKAUSIgoaZWZmaWNpZW5jeV9yZXBvcnRpbmdfY291bnQYDiABKAUSHQoVcG93ZXJfcmVwb3J0aW5nX2NvdW50GA8gASgFEiMKG3RlbXBlcmF0dXJlX3JlcG9ydGluZ19jb3VudBgQIAEoBRIhChljb250cm9sX2JvYXJkX2lzc3VlX2NvdW50GBEgASgFEhcKD2Zhbl9pc3N1ZV9jb3VudBgSIAEoBRIeChZoYXNoX2JvYXJkX2lzc3VlX2NvdW50GBMgASgFEhcKD3BzdV9pc3N1ZV9jb3VudBgUIAEoBRI0Cg1zbG90X3N0YXR1c2VzGBUgAygLMh0uY29sbGVjdGlvbi52MS5SYWNrU2xvdFN0YXR1cyIzChlHZXRDb2xsZWN0aW9uU3RhdHNSZXF1ZXN0EhYKDmNvbGxlY3Rpb25faWRzGAEgAygDIksKGkdldENvbGxlY3Rpb25TdGF0c1Jlc3BvbnNlEi0KBXN0YXRzGAEgAygLMh4uY29sbGVjdGlvbi52MS5Db2xsZWN0aW9uU3RhdHMiXgoOUmFja1Nsb3RTdGF0dXMSCwoDcm93GAEgASgFEg4KBmNvbHVtbhgCIAEoBRIvCgZzdGF0dXMYAyABKA4yHy5jb2xsZWN0aW9uLnYxLlNsb3REZXZpY2VTdGF0dXMiFgoUTGlzdFJhY2tab25lc1JlcXVlc3QiJgoVTGlzdFJhY2tab25lc1Jlc3BvbnNlEg0KBXpvbmVzGAEgAygJIhYKFExpc3RSYWNrVHlwZXNSZXF1ZXN0Ij0KCFJhY2tUeXBlEgwKBHJvd3MYASABKAUSDwoHY29sdW1ucxgCIAEoBRISCgpyYWNrX2NvdW50GAMgASgFIkQKFUxpc3RSYWNrVHlwZXNSZXNwb25zZRIrCgpyYWNrX3R5cGVzGAEgAygLMhcuY29sbGVjdGlvbi52MS5SYWNrVHlwZSKLAgoPU2F2ZVJhY2tSZXF1ZXN0EiYKDWNvbGxlY3Rpb25faWQYASABKANCCrpIB9gBASICIABIAIgBARIbCgVsYWJlbBgCIAEoCUIMukgJyAEBcgQQARhkEjIKCXJhY2tfaW5mbxgDIAEoCzIXLmNvbGxlY3Rpb24udjEuUmFja0luZm9CBrpIA8gBARI6Cg9kZXZpY2Vfc2VsZWN0b3IYBCABKAsyGS5jb21tb24udjEuRGV2aWNlU2VsZWN0b3JCBrpIA8gBARIxChBzbG90X2Fzc2lnbm1lbnRzGAUgAygLMhcuY29sbGVjdGlvbi52MS5SYWNrU2xvdEIQCg5fY29sbGVjdGlvbl9pZCJfChBTYXZlUmFja1Jlc3BvbnNlEjMKCmNvbGxlY3Rpb24YASABKAsyHy5jb2xsZWN0aW9uLnYxLkRldmljZUNvbGxlY3Rpb24SFgoOYXNzaWduZWRfY291bnQYAiABKAUqZgoOQ29sbGVjdGlvblR5cGUSHwobQ09MTEVDVElPTl9UWVBFX1VOU1BFQ0lGSUVEEAASGQoVQ09MTEVDVElPTl9UWVBFX0dST1VQEAESGAoUQ09MTEVDVElPTl9UWVBFX1JBQ0sQAiq2AQoOUmFja09yZGVySW5kZXgSIAocUkFDS19PUkRFUl9JTkRFWF9VTlNQRUNJRklFRBAAEiAKHFJBQ0tfT1JERVJfSU5ERVhfQk9UVE9NX0xFRlQQARIdChlSQUNLX09SREVSX0lOREVYX1RPUF9MRUZUEAISIQodUkFDS19PUkRFUl9JTkRFWF9CT1RUT01fUklHSFQQAxIeChpSQUNLX09SREVSX0lOREVYX1RPUF9SSUdIVBAEKnAKD1JhY2tDb29saW5nVHlwZRIhCh1SQUNLX0NPT0xJTkdfVFlQRV9VTlNQRUNJRklFRBAAEhkKFVJBQ0tfQ09PTElOR19UWVBFX0FJUhABEh8KG1JBQ0tfQ09PTElOR19UWVBFX0lNTUVSU0lPThACKt0BChBTbG90RGV2aWNlU3RhdHVzEiIKHlNMT1RfREVWSUNFX1NUQVRVU19VTlNQRUNJRklFRBAAEhwKGFNMT1RfREVWSUNFX1NUQVRVU19FTVBUWRABEh4KGlNMT1RfREVWSUNFX1NUQVRVU19IRUFMVEhZEAISJgoiU0xPVF9ERVZJQ0VfU1RBVFVTX05FRURTX0FUVEVOVElPThADEh4KGlNMT1RfREVWSUNFX1NUQVRVU19PRkZMSU5FEAQSHwobU0xPVF9ERVZJQ0VfU1RBVFVTX1NMRUVQSU5HEAUylA0KF0RldmljZUNvbGxlY3Rpb25TZXJ2aWNlEmMKEENyZWF0ZUNvbGxlY3Rpb24SJi5jb2xsZWN0aW9uLnYxLkNyZWF0ZUNvbGxlY3Rpb25SZXF1ZXN0GicuY29sbGVjdGlvbi52MS5DcmVhdGVDb2xsZWN0aW9uUmVzcG9uc2USWgoNR2V0Q29sbGVjdGlvbhIjLmNvbGxlY3Rpb24udjEuR2V0Q29sbGVjdGlvblJlcXVlc3QaJC5jb2xsZWN0aW9uLnYxLkdldENvbGxlY3Rpb25SZXNwb25zZRJjChBVcGRhdGVDb2xsZWN0aW9uEiYuY29sbGVjdGlvbi52MS5VcGRhdGVDb2xsZWN0aW9uUmVxdWVzdBonLmNvbGxlY3Rpb24udjEuVXBkYXRlQ29sbGVjdGlvblJlc3BvbnNlEmMKEERlbGV0ZUNvbGxlY3Rpb24SJi5jb2xsZWN0aW9uLnYxLkRlbGV0ZUNvbGxlY3Rpb25SZXF1ZXN0GicuY29sbGVjdGlvbi52MS5EZWxldGVDb2xsZWN0aW9uUmVzcG9uc2USYAoPTGlzdENvbGxlY3Rpb25zEiUuY29sbGVjdGlvbi52MS5MaXN0Q29sbGVjdGlvbnNSZXF1ZXN0GiYuY29sbGVjdGlvbi52MS5MaXN0Q29sbGVjdGlvbnNSZXNwb25zZRJ1ChZBZGREZXZpY2VzVG9Db2xsZWN0aW9uEiwuY29sbGVjdGlvbi52MS5BZGREZXZpY2VzVG9Db2xsZWN0aW9uUmVxdWVzdBotLmNvbGxlY3Rpb24udjEuQWRkRGV2aWNlc1RvQ29sbGVjdGlvblJlc3BvbnNlEoQBChtSZW1vdmVEZXZpY2VzRnJvbUNvbGxlY3Rpb24SMS5jb2xsZWN0aW9uLnYxLlJlbW92ZURldmljZXNGcm9tQ29sbGVjdGlvblJlcXVlc3QaMi5jb2xsZWN0aW9uLnYxLlJlbW92ZURldmljZXNGcm9tQ29sbGVjdGlvblJlc3BvbnNlEnIKFUxpc3RDb2xsZWN0aW9uTWVtYmVycxIrLmNvbGxlY3Rpb24udjEuTGlzdENvbGxlY3Rpb25NZW1iZXJzUmVxdWVzdBosLmNvbGxlY3Rpb24udjEuTGlzdENvbGxlY3Rpb25NZW1iZXJzUmVzcG9uc2USbwoUR2V0RGV2aWNlQ29sbGVjdGlvbnMSKi5jb2xsZWN0aW9uLnYxLkdldERldmljZUNvbGxlY3Rpb25zUmVxdWVzdBorLmNvbGxlY3Rpb24udjEuR2V0RGV2aWNlQ29sbGVjdGlvbnNSZXNwb25zZRJsChNTZXRSYWNrU2xvdFBvc2l0aW9uEikuY29sbGVjdGlvbi52MS5TZXRSYWNrU2xvdFBvc2l0aW9uUmVxdWVzdBoqLmNvbGxlY3Rpb24udjEuU2V0UmFja1Nsb3RQb3NpdGlvblJlc3BvbnNlEnIKFUNsZWFyUmFja1Nsb3RQb3NpdGlvbhIrLmNvbGxlY3Rpb24udjEuQ2xlYXJSYWNrU2xvdFBvc2l0aW9uUmVxdWVzdBosLmNvbGxlY3Rpb24udjEuQ2xlYXJSYWNrU2xvdFBvc2l0aW9uUmVzcG9uc2USVwoMR2V0UmFja1Nsb3RzEiIuY29sbGVjdGlvbi52MS5HZXRSYWNrU2xvdHNSZXF1ZXN0GiMuY29sbGVjdGlvbi52MS5HZXRSYWNrU2xvdHNSZXNwb25zZRJpChJHZXRDb2xsZWN0aW9uU3RhdHMSKC5jb2xsZWN0aW9uLnYxLkdldENvbGxlY3Rpb25TdGF0c1JlcXVlc3QaKS5jb2xsZWN0aW9uLnYxLkdldENvbGxlY3Rpb25TdGF0c1Jlc3BvbnNlEloKDUxpc3RSYWNrWm9uZXMSIy5jb2xsZWN0aW9uLnYxLkxpc3RSYWNrWm9uZXNSZXF1ZXN0GiQuY29sbGVjdGlvbi52MS5MaXN0UmFja1pvbmVzUmVzcG9uc2USWgoNTGlzdFJhY2tUeXBlcxIjLmNvbGxlY3Rpb24udjEuTGlzdFJhY2tUeXBlc1JlcXVlc3QaJC5jb2xsZWN0aW9uLnYxLkxpc3RSYWNrVHlwZXNSZXNwb25zZRJLCghTYXZlUmFjaxIeLmNvbGxlY3Rpb24udjEuU2F2ZVJhY2tSZXF1ZXN0Gh8uY29sbGVjdGlvbi52MS5TYXZlUmFja1Jlc3BvbnNlQsgBChFjb20uY29sbGVjdGlvbi52MUIPQ29sbGVjdGlvblByb3RvUAFaTWdpdGh1Yi5jb20vYmxvY2svcHJvdG8tZmxlZXQvc2VydmVyL2dlbmVyYXRlZC9ncnBjL2NvbGxlY3Rpb24vdjE7Y29sbGVjdGlvbnYxogIDQ1hYqgINQ29sbGVjdGlvbi5WMcoCDUNvbGxlY3Rpb25cVjHiAhlDb2xsZWN0aW9uXFYxXEdQQk1ldGFkYXRh6gIOQ29sbGVjdGlvbjo6VjFiBnByb3RvMw", + [ + file_google_protobuf_timestamp, + file_buf_validate_validate, + file_common_v1_device_selector, + file_common_v1_sort, + file_errors_v1_errors, + ], + ); + +/** + * DeviceCollection represents a group or rack of devices + * + * @generated from message collection.v1.DeviceCollection + */ +export type DeviceCollection = Message<"collection.v1.DeviceCollection"> & { + /** + * Unique identifier for the collection + * + * @generated from field: int64 id = 1; + */ + id: bigint; + + /** + * Type of collection (group or rack) + * + * @generated from field: collection.v1.CollectionType type = 2; + */ + type: CollectionType; + + /** + * Human-readable label for the collection + * + * @generated from field: string label = 3; + */ + label: string; + + /** + * Optional description of the collection's purpose + * + * @generated from field: string description = 4; + */ + description: string; + + /** + * Number of devices in this collection + * + * @generated from field: int32 device_count = 5; + */ + deviceCount: number; + + /** + * When the collection was created + * + * @generated from field: google.protobuf.Timestamp created_at = 6; + */ + createdAt?: Timestamp; + + /** + * When the collection was last updated + * + * @generated from field: google.protobuf.Timestamp updated_at = 7; + */ + updatedAt?: Timestamp; + + /** + * Type-specific metadata (enforces only one detail type is set) + * + * @generated from oneof collection.v1.DeviceCollection.type_details + */ + typeDetails: + | { + /** + * @generated from field: collection.v1.RackInfo rack_info = 8; + */ + value: RackInfo; + case: "rackInfo"; + } + | { + /** + * @generated from field: collection.v1.GroupInfo group_info = 9; + */ + value: GroupInfo; + case: "groupInfo"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message collection.v1.DeviceCollection. + * Use `create(DeviceCollectionSchema)` to create a new message. + */ +export const DeviceCollectionSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 0); + +/** + * Rack-specific metadata for rack-type collections + * + * @generated from message collection.v1.RackInfo + */ +export type RackInfo = Message<"collection.v1.RackInfo"> & { + /** + * Number of rows in the rack grid + * + * @generated from field: int32 rows = 1; + */ + rows: number; + + /** + * Number of columns in the rack grid + * + * @generated from field: int32 columns = 2; + */ + columns: number; + + /** + * Physical zone description (e.g. building, room, area) + * + * @generated from field: string zone = 3; + */ + zone: string; + + /** + * Order index defining where numbering starts + * + * @generated from field: collection.v1.RackOrderIndex order_index = 4; + */ + orderIndex: RackOrderIndex; + + /** + * Cooling type for this rack + * + * @generated from field: collection.v1.RackCoolingType cooling_type = 5; + */ + coolingType: RackCoolingType; +}; + +/** + * Describes the message collection.v1.RackInfo. + * Use `create(RackInfoSchema)` to create a new message. + */ +export const RackInfoSchema: GenMessage = /*@__PURE__*/ messageDesc(file_collection_v1_collection, 1); + +/** + * Group-specific metadata for group-type collections + * Reserved for future group-specific fields (e.g., tags, policies) + * + * @generated from message collection.v1.GroupInfo + */ +export type GroupInfo = Message<"collection.v1.GroupInfo"> & {}; + +/** + * Describes the message collection.v1.GroupInfo. + * Use `create(GroupInfoSchema)` to create a new message. + */ +export const GroupInfoSchema: GenMessage = /*@__PURE__*/ messageDesc(file_collection_v1_collection, 2); + +/** + * CollectionMember represents a device in a collection + * + * @generated from message collection.v1.CollectionMember + */ +export type CollectionMember = Message<"collection.v1.CollectionMember"> & { + /** + * Device identifier of the member + * + * @generated from field: string device_identifier = 1; + */ + deviceIdentifier: string; + + /** + * When the device was added to the collection + * + * @generated from field: google.protobuf.Timestamp added_at = 2; + */ + addedAt?: Timestamp; + + /** + * Type-specific member details + * + * @generated from oneof collection.v1.CollectionMember.member_details + */ + memberDetails: + | { + /** + * @generated from field: collection.v1.RackMemberDetails rack = 3; + */ + value: RackMemberDetails; + case: "rack"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message collection.v1.CollectionMember. + * Use `create(CollectionMemberSchema)` to create a new message. + */ +export const CollectionMemberSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 3); + +/** + * Rack-specific details for a collection member + * + * @generated from message collection.v1.RackMemberDetails + */ +export type RackMemberDetails = Message<"collection.v1.RackMemberDetails"> & { + /** + * Slot position of the device within the rack + * + * @generated from field: collection.v1.RackSlotPosition slot_position = 1; + */ + slotPosition?: RackSlotPosition; +}; + +/** + * Describes the message collection.v1.RackMemberDetails. + * Use `create(RackMemberDetailsSchema)` to create a new message. + */ +export const RackMemberDetailsSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 4); + +/** + * Position of a device within a rack + * + * @generated from message collection.v1.RackSlotPosition + */ +export type RackSlotPosition = Message<"collection.v1.RackSlotPosition"> & { + /** + * Row position (0-indexed) + * + * @generated from field: int32 row = 1; + */ + row: number; + + /** + * Column position (0-indexed) + * + * @generated from field: int32 column = 2; + */ + column: number; +}; + +/** + * Describes the message collection.v1.RackSlotPosition. + * Use `create(RackSlotPositionSchema)` to create a new message. + */ +export const RackSlotPositionSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 5); + +/** + * Request to create a new collection + * + * @generated from message collection.v1.CreateCollectionRequest + */ +export type CreateCollectionRequest = Message<"collection.v1.CreateCollectionRequest"> & { + /** + * Type of collection to create (required) + * + * @generated from field: collection.v1.CollectionType type = 1; + */ + type: CollectionType; + + /** + * Label for the collection (required, 1-100 characters) + * + * @generated from field: string label = 2; + */ + label: string; + + /** + * Optional description (max 500 characters) + * + * @generated from field: string description = 3; + */ + description: string; + + /** + * Type-specific metadata (rack_info required for racks, group_info optional for groups) + * + * @generated from oneof collection.v1.CreateCollectionRequest.type_details + */ + typeDetails: + | { + /** + * @generated from field: collection.v1.RackInfo rack_info = 4; + */ + value: RackInfo; + case: "rackInfo"; + } + | { + /** + * @generated from field: collection.v1.GroupInfo group_info = 5; + */ + value: GroupInfo; + case: "groupInfo"; + } + | { case: undefined; value?: undefined }; + + /** + * Optional: devices to add atomically when creating the collection. + * If provided, devices are added in the same transaction as collection creation. + * + * @generated from field: optional common.v1.DeviceSelector device_selector = 6; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message collection.v1.CreateCollectionRequest. + * Use `create(CreateCollectionRequestSchema)` to create a new message. + */ +export const CreateCollectionRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 6); + +/** + * Response after creating a collection + * + * @generated from message collection.v1.CreateCollectionResponse + */ +export type CreateCollectionResponse = Message<"collection.v1.CreateCollectionResponse"> & { + /** + * The newly created collection + * + * @generated from field: collection.v1.DeviceCollection collection = 1; + */ + collection?: DeviceCollection; + + /** + * Number of devices added to the collection (0 if no device_selector was provided) + * + * @generated from field: int32 added_count = 2; + */ + addedCount: number; +}; + +/** + * Describes the message collection.v1.CreateCollectionResponse. + * Use `create(CreateCollectionResponseSchema)` to create a new message. + */ +export const CreateCollectionResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 7); + +/** + * Request to get a collection by ID + * + * @generated from message collection.v1.GetCollectionRequest + */ +export type GetCollectionRequest = Message<"collection.v1.GetCollectionRequest"> & { + /** + * ID of the collection to retrieve + * + * @generated from field: int64 collection_id = 1; + */ + collectionId: bigint; +}; + +/** + * Describes the message collection.v1.GetCollectionRequest. + * Use `create(GetCollectionRequestSchema)` to create a new message. + */ +export const GetCollectionRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 8); + +/** + * Response containing the requested collection + * + * @generated from message collection.v1.GetCollectionResponse + */ +export type GetCollectionResponse = Message<"collection.v1.GetCollectionResponse"> & { + /** + * The requested collection + * + * @generated from field: collection.v1.DeviceCollection collection = 1; + */ + collection?: DeviceCollection; +}; + +/** + * Describes the message collection.v1.GetCollectionResponse. + * Use `create(GetCollectionResponseSchema)` to create a new message. + */ +export const GetCollectionResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 9); + +/** + * Request to update a collection + * + * @generated from message collection.v1.UpdateCollectionRequest + */ +export type UpdateCollectionRequest = Message<"collection.v1.UpdateCollectionRequest"> & { + /** + * ID of the collection to update + * + * @generated from field: int64 collection_id = 1; + */ + collectionId: bigint; + + /** + * New label (optional, 1-100 characters if provided) + * + * @generated from field: optional string label = 2; + */ + label?: string; + + /** + * New description (optional, max 500 characters if provided). + * Omit the field to leave unchanged; set to empty string to clear. + * + * @generated from field: optional string description = 3; + */ + description?: string; + + /** + * Type-specific metadata updates (only applicable for the collection's type) + * + * @generated from oneof collection.v1.UpdateCollectionRequest.type_details + */ + typeDetails: + | { + /** + * @generated from field: collection.v1.RackInfo rack_info = 4; + */ + value: RackInfo; + case: "rackInfo"; + } + | { + /** + * @generated from field: collection.v1.GroupInfo group_info = 5; + */ + value: GroupInfo; + case: "groupInfo"; + } + | { case: undefined; value?: undefined }; + + /** + * Optional: atomically replace all collection members with the selected devices. + * + * @generated from field: common.v1.DeviceSelector device_selector = 6; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message collection.v1.UpdateCollectionRequest. + * Use `create(UpdateCollectionRequestSchema)` to create a new message. + */ +export const UpdateCollectionRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 10); + +/** + * Response after updating a collection + * + * @generated from message collection.v1.UpdateCollectionResponse + */ +export type UpdateCollectionResponse = Message<"collection.v1.UpdateCollectionResponse"> & { + /** + * The updated collection + * + * @generated from field: collection.v1.DeviceCollection collection = 1; + */ + collection?: DeviceCollection; +}; + +/** + * Describes the message collection.v1.UpdateCollectionResponse. + * Use `create(UpdateCollectionResponseSchema)` to create a new message. + */ +export const UpdateCollectionResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 11); + +/** + * Request to delete a collection + * + * @generated from message collection.v1.DeleteCollectionRequest + */ +export type DeleteCollectionRequest = Message<"collection.v1.DeleteCollectionRequest"> & { + /** + * ID of the collection to delete + * + * @generated from field: int64 collection_id = 1; + */ + collectionId: bigint; +}; + +/** + * Describes the message collection.v1.DeleteCollectionRequest. + * Use `create(DeleteCollectionRequestSchema)` to create a new message. + */ +export const DeleteCollectionRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 12); + +/** + * Response after deleting a collection + * + * Empty response - success indicated by gRPC status + * + * @generated from message collection.v1.DeleteCollectionResponse + */ +export type DeleteCollectionResponse = Message<"collection.v1.DeleteCollectionResponse"> & {}; + +/** + * Describes the message collection.v1.DeleteCollectionResponse. + * Use `create(DeleteCollectionResponseSchema)` to create a new message. + */ +export const DeleteCollectionResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 13); + +/** + * Request to list all collections + * + * @generated from message collection.v1.ListCollectionsRequest + */ +export type ListCollectionsRequest = Message<"collection.v1.ListCollectionsRequest"> & { + /** + * Filter by collection type (optional, returns all types if unspecified) + * + * @generated from field: collection.v1.CollectionType type = 1; + */ + type: CollectionType; + + /** + * Maximum number of collections to return (0 = server default) + * + * @generated from field: int32 page_size = 2; + */ + pageSize: number; + + /** + * Pagination cursor from a previous response + * + * @generated from field: string page_token = 3; + */ + pageToken: string; + + /** + * Sort configuration (defaults to name ascending). + * Supported fields: SORT_FIELD_NAME, SORT_FIELD_DEVICE_COUNT, SORT_FIELD_ISSUE_COUNT. + * + * @generated from field: common.v1.SortConfig sort = 4; + */ + sort?: SortConfig; + + /** + * Filter by collections containing devices with open errors of these component types. + * When non-empty, only collections with at least one device having an open error + * matching any of the specified component types are returned. + * + * @generated from field: repeated errors.v1.ComponentType error_component_types = 5; + */ + errorComponentTypes: ComponentType[]; + + /** + * Filter by rack zones. Only valid when type is RACK. + * When non-empty, only racks in any of the specified zones are returned. + * + * @generated from field: repeated string zones = 6; + */ + zones: string[]; +}; + +/** + * Describes the message collection.v1.ListCollectionsRequest. + * Use `create(ListCollectionsRequestSchema)` to create a new message. + */ +export const ListCollectionsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 14); + +/** + * Response containing collections + * + * @generated from message collection.v1.ListCollectionsResponse + */ +export type ListCollectionsResponse = Message<"collection.v1.ListCollectionsResponse"> & { + /** + * List of collections ordered by label + * + * @generated from field: repeated collection.v1.DeviceCollection collections = 1; + */ + collections: DeviceCollection[]; + + /** + * Cursor for the next page, empty if no more results + * + * @generated from field: string next_page_token = 2; + */ + nextPageToken: string; + + /** + * Total number of collections matching the request filters + * + * @generated from field: int32 total_count = 3; + */ + totalCount: number; +}; + +/** + * Describes the message collection.v1.ListCollectionsResponse. + * Use `create(ListCollectionsResponseSchema)` to create a new message. + */ +export const ListCollectionsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 15); + +/** + * Request to add devices to a collection + * + * @generated from message collection.v1.AddDevicesToCollectionRequest + */ +export type AddDevicesToCollectionRequest = Message<"collection.v1.AddDevicesToCollectionRequest"> & { + /** + * ID of the collection to add devices to + * + * @generated from field: int64 collection_id = 1; + */ + collectionId: bigint; + + /** + * Devices to add: specific list or all paired devices + * + * @generated from field: common.v1.DeviceSelector device_selector = 2; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message collection.v1.AddDevicesToCollectionRequest. + * Use `create(AddDevicesToCollectionRequestSchema)` to create a new message. + */ +export const AddDevicesToCollectionRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 16); + +/** + * Response after adding devices to a collection + * + * @generated from message collection.v1.AddDevicesToCollectionResponse + */ +export type AddDevicesToCollectionResponse = Message<"collection.v1.AddDevicesToCollectionResponse"> & { + /** + * ID of the collection devices were added to + * + * @generated from field: int64 collection_id = 1; + */ + collectionId: bigint; + + /** + * Number of devices successfully added + * May be less than requested if some devices were already members + * + * @generated from field: int32 added_count = 2; + */ + addedCount: number; +}; + +/** + * Describes the message collection.v1.AddDevicesToCollectionResponse. + * Use `create(AddDevicesToCollectionResponseSchema)` to create a new message. + */ +export const AddDevicesToCollectionResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 17); + +/** + * Request to remove devices from a collection + * + * @generated from message collection.v1.RemoveDevicesFromCollectionRequest + */ +export type RemoveDevicesFromCollectionRequest = Message<"collection.v1.RemoveDevicesFromCollectionRequest"> & { + /** + * ID of the collection to remove devices from + * + * @generated from field: int64 collection_id = 1; + */ + collectionId: bigint; + + /** + * Devices to remove: specific list or all paired devices + * + * @generated from field: common.v1.DeviceSelector device_selector = 2; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message collection.v1.RemoveDevicesFromCollectionRequest. + * Use `create(RemoveDevicesFromCollectionRequestSchema)` to create a new message. + */ +export const RemoveDevicesFromCollectionRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 18); + +/** + * Response after removing devices from a collection + * + * @generated from message collection.v1.RemoveDevicesFromCollectionResponse + */ +export type RemoveDevicesFromCollectionResponse = Message<"collection.v1.RemoveDevicesFromCollectionResponse"> & { + /** + * Number of devices successfully removed + * + * @generated from field: int32 removed_count = 1; + */ + removedCount: number; +}; + +/** + * Describes the message collection.v1.RemoveDevicesFromCollectionResponse. + * Use `create(RemoveDevicesFromCollectionResponseSchema)` to create a new message. + */ +export const RemoveDevicesFromCollectionResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 19); + +/** + * Request to list members of a collection + * + * @generated from message collection.v1.ListCollectionMembersRequest + */ +export type ListCollectionMembersRequest = Message<"collection.v1.ListCollectionMembersRequest"> & { + /** + * ID of the collection to list members for + * + * @generated from field: int64 collection_id = 1; + */ + collectionId: bigint; + + /** + * Maximum number of members to return (default: all) + * + * @generated from field: int32 page_size = 2; + */ + pageSize: number; + + /** + * Pagination cursor from a previous response + * + * @generated from field: string page_token = 3; + */ + pageToken: string; +}; + +/** + * Describes the message collection.v1.ListCollectionMembersRequest. + * Use `create(ListCollectionMembersRequestSchema)` to create a new message. + */ +export const ListCollectionMembersRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 20); + +/** + * Response containing collection members + * + * @generated from message collection.v1.ListCollectionMembersResponse + */ +export type ListCollectionMembersResponse = Message<"collection.v1.ListCollectionMembersResponse"> & { + /** + * List of members ordered by when they were added (newest first) + * + * @generated from field: repeated collection.v1.CollectionMember members = 1; + */ + members: CollectionMember[]; + + /** + * Cursor for the next page, empty if no more results + * + * @generated from field: string next_page_token = 2; + */ + nextPageToken: string; +}; + +/** + * Describes the message collection.v1.ListCollectionMembersResponse. + * Use `create(ListCollectionMembersResponseSchema)` to create a new message. + */ +export const ListCollectionMembersResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 21); + +/** + * Request to get collections for a device + * + * @generated from message collection.v1.GetDeviceCollectionsRequest + */ +export type GetDeviceCollectionsRequest = Message<"collection.v1.GetDeviceCollectionsRequest"> & { + /** + * Device identifier to look up + * + * @generated from field: string device_identifier = 1; + */ + deviceIdentifier: string; + + /** + * Filter by collection type (optional, returns all types if unspecified) + * + * @generated from field: collection.v1.CollectionType type = 2; + */ + type: CollectionType; +}; + +/** + * Describes the message collection.v1.GetDeviceCollectionsRequest. + * Use `create(GetDeviceCollectionsRequestSchema)` to create a new message. + */ +export const GetDeviceCollectionsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 22); + +/** + * Response containing collections the device belongs to + * + * @generated from message collection.v1.GetDeviceCollectionsResponse + */ +export type GetDeviceCollectionsResponse = Message<"collection.v1.GetDeviceCollectionsResponse"> & { + /** + * Collections the device belongs to, ordered by label + * + * @generated from field: repeated collection.v1.DeviceCollection collections = 1; + */ + collections: DeviceCollection[]; +}; + +/** + * Describes the message collection.v1.GetDeviceCollectionsResponse. + * Use `create(GetDeviceCollectionsResponseSchema)` to create a new message. + */ +export const GetDeviceCollectionsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 23); + +/** + * Request to set a device's slot position within a rack + * + * @generated from message collection.v1.SetRackSlotPositionRequest + */ +export type SetRackSlotPositionRequest = Message<"collection.v1.SetRackSlotPositionRequest"> & { + /** + * ID of the rack collection + * + * @generated from field: int64 collection_id = 1; + */ + collectionId: bigint; + + /** + * Device to position + * + * @generated from field: string device_identifier = 2; + */ + deviceIdentifier: string; + + /** + * Target slot position + * + * @generated from field: collection.v1.RackSlotPosition position = 3; + */ + position?: RackSlotPosition; +}; + +/** + * Describes the message collection.v1.SetRackSlotPositionRequest. + * Use `create(SetRackSlotPositionRequestSchema)` to create a new message. + */ +export const SetRackSlotPositionRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 24); + +/** + * Response after setting a rack slot position + * + * @generated from message collection.v1.SetRackSlotPositionResponse + */ +export type SetRackSlotPositionResponse = Message<"collection.v1.SetRackSlotPositionResponse"> & { + /** + * ID of the rack collection + * + * @generated from field: int64 collection_id = 1; + */ + collectionId: bigint; + + /** + * The slot that was set + * + * @generated from field: collection.v1.RackSlot slot = 2; + */ + slot?: RackSlot; +}; + +/** + * Describes the message collection.v1.SetRackSlotPositionResponse. + * Use `create(SetRackSlotPositionResponseSchema)` to create a new message. + */ +export const SetRackSlotPositionResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 25); + +/** + * Request to clear a device's slot position within a rack + * + * @generated from message collection.v1.ClearRackSlotPositionRequest + */ +export type ClearRackSlotPositionRequest = Message<"collection.v1.ClearRackSlotPositionRequest"> & { + /** + * ID of the rack collection + * + * @generated from field: int64 collection_id = 1; + */ + collectionId: bigint; + + /** + * Device to unposition + * + * @generated from field: string device_identifier = 2; + */ + deviceIdentifier: string; +}; + +/** + * Describes the message collection.v1.ClearRackSlotPositionRequest. + * Use `create(ClearRackSlotPositionRequestSchema)` to create a new message. + */ +export const ClearRackSlotPositionRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 26); + +/** + * Response after clearing a rack slot position + * + * @generated from message collection.v1.ClearRackSlotPositionResponse + */ +export type ClearRackSlotPositionResponse = Message<"collection.v1.ClearRackSlotPositionResponse"> & {}; + +/** + * Describes the message collection.v1.ClearRackSlotPositionResponse. + * Use `create(ClearRackSlotPositionResponseSchema)` to create a new message. + */ +export const ClearRackSlotPositionResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 27); + +/** + * Request to list all occupied slots in a rack + * + * @generated from message collection.v1.GetRackSlotsRequest + */ +export type GetRackSlotsRequest = Message<"collection.v1.GetRackSlotsRequest"> & { + /** + * ID of the rack collection + * + * @generated from field: int64 collection_id = 1; + */ + collectionId: bigint; +}; + +/** + * Describes the message collection.v1.GetRackSlotsRequest. + * Use `create(GetRackSlotsRequestSchema)` to create a new message. + */ +export const GetRackSlotsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 28); + +/** + * Represents a device assigned to a specific slot in a rack + * + * @generated from message collection.v1.RackSlot + */ +export type RackSlot = Message<"collection.v1.RackSlot"> & { + /** + * Device in this slot + * + * @generated from field: string device_identifier = 1; + */ + deviceIdentifier: string; + + /** + * Slot position within the rack + * + * @generated from field: collection.v1.RackSlotPosition position = 2; + */ + position?: RackSlotPosition; +}; + +/** + * Describes the message collection.v1.RackSlot. + * Use `create(RackSlotSchema)` to create a new message. + */ +export const RackSlotSchema: GenMessage = /*@__PURE__*/ messageDesc(file_collection_v1_collection, 29); + +/** + * Response containing all occupied rack slots + * + * @generated from message collection.v1.GetRackSlotsResponse + */ +export type GetRackSlotsResponse = Message<"collection.v1.GetRackSlotsResponse"> & { + /** + * Occupied slots ordered by row then column + * + * @generated from field: repeated collection.v1.RackSlot slots = 1; + */ + slots: RackSlot[]; +}; + +/** + * Describes the message collection.v1.GetRackSlotsResponse. + * Use `create(GetRackSlotsResponseSchema)` to create a new message. + */ +export const GetRackSlotsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 30); + +/** + * Aggregated telemetry stats for a single collection + * + * @generated from message collection.v1.CollectionStats + */ +export type CollectionStats = Message<"collection.v1.CollectionStats"> & { + /** + * Collection identifier + * + * @generated from field: int64 collection_id = 1; + */ + collectionId: bigint; + + /** + * Total number of devices in the collection + * + * @generated from field: int32 device_count = 2; + */ + deviceCount: number; + + /** + * Number of devices with recent telemetry data + * + * @generated from field: int32 reporting_count = 3; + */ + reportingCount: number; + + /** + * Aggregated telemetry (totals/averages across reporting devices) + * + * @generated from field: double total_hashrate_ths = 4; + */ + totalHashrateThs: number; + + /** + * @generated from field: double avg_efficiency_jth = 5; + */ + avgEfficiencyJth: number; + + /** + * @generated from field: double total_power_kw = 6; + */ + totalPowerKw: number; + + /** + * @generated from field: double min_temperature_c = 7; + */ + minTemperatureC: number; + + /** + * @generated from field: double max_temperature_c = 8; + */ + maxTemperatureC: number; + + /** + * Fleet health state counts (mirrors dashboard FleetHealth buckets) + * + * ACTIVE + no auth issues + no actionable errors + * + * @generated from field: int32 hashing_count = 9; + */ + hashingCount: number; + + /** + * ERROR/NEEDS_MINING_POOL/AUTH_NEEDED or has open errors + * + * @generated from field: int32 broken_count = 10; + */ + brokenCount: number; + + /** + * OFFLINE or NULL status + * + * @generated from field: int32 offline_count = 11; + */ + offlineCount: number; + + /** + * MAINTENANCE or INACTIVE + * + * @generated from field: int32 sleeping_count = 12; + */ + sleepingCount: number; + + /** + * Per-metric reporting counts (devices that report each specific metric) + * + * @generated from field: int32 hashrate_reporting_count = 13; + */ + hashrateReportingCount: number; + + /** + * @generated from field: int32 efficiency_reporting_count = 14; + */ + efficiencyReportingCount: number; + + /** + * @generated from field: int32 power_reporting_count = 15; + */ + powerReportingCount: number; + + /** + * @generated from field: int32 temperature_reporting_count = 16; + */ + temperatureReportingCount: number; + + /** + * Component issue counts (number of devices with open errors by component type) + * + * @generated from field: int32 control_board_issue_count = 17; + */ + controlBoardIssueCount: number; + + /** + * @generated from field: int32 fan_issue_count = 18; + */ + fanIssueCount: number; + + /** + * @generated from field: int32 hash_board_issue_count = 19; + */ + hashBoardIssueCount: number; + + /** + * @generated from field: int32 psu_issue_count = 20; + */ + psuIssueCount: number; + + /** + * Per-slot device status for rack-type collections (empty for groups). + * Contains one entry per row×column position, including empty slots. + * + * @generated from field: repeated collection.v1.RackSlotStatus slot_statuses = 21; + */ + slotStatuses: RackSlotStatus[]; +}; + +/** + * Describes the message collection.v1.CollectionStats. + * Use `create(CollectionStatsSchema)` to create a new message. + */ +export const CollectionStatsSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 31); + +/** + * Request to get aggregated stats for collections + * + * @generated from message collection.v1.GetCollectionStatsRequest + */ +export type GetCollectionStatsRequest = Message<"collection.v1.GetCollectionStatsRequest"> & { + /** + * Collection IDs to get stats for + * + * @generated from field: repeated int64 collection_ids = 1; + */ + collectionIds: bigint[]; +}; + +/** + * Describes the message collection.v1.GetCollectionStatsRequest. + * Use `create(GetCollectionStatsRequestSchema)` to create a new message. + */ +export const GetCollectionStatsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 32); + +/** + * Response containing stats for each requested collection + * + * @generated from message collection.v1.GetCollectionStatsResponse + */ +export type GetCollectionStatsResponse = Message<"collection.v1.GetCollectionStatsResponse"> & { + /** + * Stats per collection (one entry per requested collection ID) + * + * @generated from field: repeated collection.v1.CollectionStats stats = 1; + */ + stats: CollectionStats[]; +}; + +/** + * Describes the message collection.v1.GetCollectionStatsResponse. + * Use `create(GetCollectionStatsResponseSchema)` to create a new message. + */ +export const GetCollectionStatsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 33); + +/** + * Status of a single slot in a rack grid + * + * @generated from message collection.v1.RackSlotStatus + */ +export type RackSlotStatus = Message<"collection.v1.RackSlotStatus"> & { + /** + * Row position (0-indexed) + * + * @generated from field: int32 row = 1; + */ + row: number; + + /** + * Column position (0-indexed) + * + * @generated from field: int32 column = 2; + */ + column: number; + + /** + * Device status for this slot + * + * @generated from field: collection.v1.SlotDeviceStatus status = 3; + */ + status: SlotDeviceStatus; +}; + +/** + * Describes the message collection.v1.RackSlotStatus. + * Use `create(RackSlotStatusSchema)` to create a new message. + */ +export const RackSlotStatusSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 34); + +/** + * Request to list all distinct rack zones for the organization + * + * @generated from message collection.v1.ListRackZonesRequest + */ +export type ListRackZonesRequest = Message<"collection.v1.ListRackZonesRequest"> & {}; + +/** + * Describes the message collection.v1.ListRackZonesRequest. + * Use `create(ListRackZonesRequestSchema)` to create a new message. + */ +export const ListRackZonesRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 35); + +/** + * Response containing all distinct rack zones + * + * @generated from message collection.v1.ListRackZonesResponse + */ +export type ListRackZonesResponse = Message<"collection.v1.ListRackZonesResponse"> & { + /** + * Distinct zone strings across all racks, sorted alphabetically + * + * @generated from field: repeated string zones = 1; + */ + zones: string[]; +}; + +/** + * Describes the message collection.v1.ListRackZonesResponse. + * Use `create(ListRackZonesResponseSchema)` to create a new message. + */ +export const ListRackZonesResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 36); + +/** + * Request to list all distinct rack types for the organization + * + * @generated from message collection.v1.ListRackTypesRequest + */ +export type ListRackTypesRequest = Message<"collection.v1.ListRackTypesRequest"> & {}; + +/** + * Describes the message collection.v1.ListRackTypesRequest. + * Use `create(ListRackTypesRequestSchema)` to create a new message. + */ +export const ListRackTypesRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 37); + +/** + * A rack type defined by its row/column dimensions and how many racks use it + * + * @generated from message collection.v1.RackType + */ +export type RackType = Message<"collection.v1.RackType"> & { + /** + * Number of rows + * + * @generated from field: int32 rows = 1; + */ + rows: number; + + /** + * Number of columns + * + * @generated from field: int32 columns = 2; + */ + columns: number; + + /** + * Number of racks using this layout + * + * @generated from field: int32 rack_count = 3; + */ + rackCount: number; +}; + +/** + * Describes the message collection.v1.RackType. + * Use `create(RackTypeSchema)` to create a new message. + */ +export const RackTypeSchema: GenMessage = /*@__PURE__*/ messageDesc(file_collection_v1_collection, 38); + +/** + * Response containing all distinct rack types + * + * @generated from message collection.v1.ListRackTypesResponse + */ +export type ListRackTypesResponse = Message<"collection.v1.ListRackTypesResponse"> & { + /** + * Distinct rack types ordered by most recently created rack using that layout + * + * @generated from field: repeated collection.v1.RackType rack_types = 1; + */ + rackTypes: RackType[]; +}; + +/** + * Describes the message collection.v1.ListRackTypesResponse. + * Use `create(ListRackTypesResponseSchema)` to create a new message. + */ +export const ListRackTypesResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 39); + +/** + * Request to atomically create or update a rack with membership and slot assignments. + * + * @generated from message collection.v1.SaveRackRequest + */ +export type SaveRackRequest = Message<"collection.v1.SaveRackRequest"> & { + /** + * ID of an existing rack to update. Omit to create a new rack. + * + * @generated from field: optional int64 collection_id = 1; + */ + collectionId?: bigint; + + /** + * Label for the rack (required, 1-100 characters) + * + * @generated from field: string label = 2; + */ + label: string; + + /** + * Rack-specific metadata (required) + * + * @generated from field: collection.v1.RackInfo rack_info = 3; + */ + rackInfo?: RackInfo; + + /** + * Devices that should be members of this rack. + * Replaces all existing members atomically. + * + * @generated from field: common.v1.DeviceSelector device_selector = 4; + */ + deviceSelector?: DeviceSelector; + + /** + * Slot assignments for devices within the rack. + * Only devices included in device_selector may be assigned slots. + * Devices not listed here will be members without a slot position. + * + * @generated from field: repeated collection.v1.RackSlot slot_assignments = 5; + */ + slotAssignments: RackSlot[]; +}; + +/** + * Describes the message collection.v1.SaveRackRequest. + * Use `create(SaveRackRequestSchema)` to create a new message. + */ +export const SaveRackRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 40); + +/** + * Response after saving a rack. + * + * @generated from message collection.v1.SaveRackResponse + */ +export type SaveRackResponse = Message<"collection.v1.SaveRackResponse"> & { + /** + * The created or updated rack collection + * + * @generated from field: collection.v1.DeviceCollection collection = 1; + */ + collection?: DeviceCollection; + + /** + * Number of slot positions assigned + * + * @generated from field: int32 assigned_count = 2; + */ + assignedCount: number; +}; + +/** + * Describes the message collection.v1.SaveRackResponse. + * Use `create(SaveRackResponseSchema)` to create a new message. + */ +export const SaveRackResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 41); + +/** + * Type of collection + * + * @generated from enum collection.v1.CollectionType + */ +export enum CollectionType { + /** + * Unspecified type - returns all types when filtering + * + * @generated from enum value: COLLECTION_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Group: many-to-many relationship (device can belong to multiple groups) + * + * @generated from enum value: COLLECTION_TYPE_GROUP = 1; + */ + GROUP = 1, + + /** + * Rack: one-to-one relationship (device can only be in one rack) + * + * @generated from enum value: COLLECTION_TYPE_RACK = 2; + */ + RACK = 2, +} + +/** + * Describes the enum collection.v1.CollectionType. + */ +export const CollectionTypeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_collection_v1_collection, 0); + +/** + * Order index defining where row/column numbering starts in a rack + * + * @generated from enum collection.v1.RackOrderIndex + */ +export enum RackOrderIndex { + /** + * @generated from enum value: RACK_ORDER_INDEX_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: RACK_ORDER_INDEX_BOTTOM_LEFT = 1; + */ + BOTTOM_LEFT = 1, + + /** + * @generated from enum value: RACK_ORDER_INDEX_TOP_LEFT = 2; + */ + TOP_LEFT = 2, + + /** + * @generated from enum value: RACK_ORDER_INDEX_BOTTOM_RIGHT = 3; + */ + BOTTOM_RIGHT = 3, + + /** + * @generated from enum value: RACK_ORDER_INDEX_TOP_RIGHT = 4; + */ + TOP_RIGHT = 4, +} + +/** + * Describes the enum collection.v1.RackOrderIndex. + */ +export const RackOrderIndexSchema: GenEnum = /*@__PURE__*/ enumDesc(file_collection_v1_collection, 1); + +/** + * Cooling type for a rack + * + * @generated from enum collection.v1.RackCoolingType + */ +export enum RackCoolingType { + /** + * @generated from enum value: RACK_COOLING_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: RACK_COOLING_TYPE_AIR = 1; + */ + AIR = 1, + + /** + * @generated from enum value: RACK_COOLING_TYPE_IMMERSION = 2; + */ + IMMERSION = 2, +} + +/** + * Describes the enum collection.v1.RackCoolingType. + */ +export const RackCoolingTypeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_collection_v1_collection, 2); + +/** + * Status of a device in a specific rack slot position + * + * @generated from enum collection.v1.SlotDeviceStatus + */ +export enum SlotDeviceStatus { + /** + * @generated from enum value: SLOT_DEVICE_STATUS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: SLOT_DEVICE_STATUS_EMPTY = 1; + */ + EMPTY = 1, + + /** + * @generated from enum value: SLOT_DEVICE_STATUS_HEALTHY = 2; + */ + HEALTHY = 2, + + /** + * @generated from enum value: SLOT_DEVICE_STATUS_NEEDS_ATTENTION = 3; + */ + NEEDS_ATTENTION = 3, + + /** + * @generated from enum value: SLOT_DEVICE_STATUS_OFFLINE = 4; + */ + OFFLINE = 4, + + /** + * @generated from enum value: SLOT_DEVICE_STATUS_SLEEPING = 5; + */ + SLEEPING = 5, +} + +/** + * Describes the enum collection.v1.SlotDeviceStatus. + */ +export const SlotDeviceStatusSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_collection_v1_collection, 3); + +/** + * Deprecated: Use device_set.v1.DeviceSetService instead. + * Service for managing device collections (groups and racks) + * Collections allow grouping devices for filtering and bulk operations + * + * @generated from service collection.v1.DeviceCollectionService + */ +export const DeviceCollectionService: GenService<{ + /** + * Creates a new collection + * + * @generated from rpc collection.v1.DeviceCollectionService.CreateCollection + */ + createCollection: { + methodKind: "unary"; + input: typeof CreateCollectionRequestSchema; + output: typeof CreateCollectionResponseSchema; + }; + /** + * Gets a collection by ID + * + * @generated from rpc collection.v1.DeviceCollectionService.GetCollection + */ + getCollection: { + methodKind: "unary"; + input: typeof GetCollectionRequestSchema; + output: typeof GetCollectionResponseSchema; + }; + /** + * Updates a collection's label or description + * + * @generated from rpc collection.v1.DeviceCollectionService.UpdateCollection + */ + updateCollection: { + methodKind: "unary"; + input: typeof UpdateCollectionRequestSchema; + output: typeof UpdateCollectionResponseSchema; + }; + /** + * Deletes a collection (soft delete) + * + * @generated from rpc collection.v1.DeviceCollectionService.DeleteCollection + */ + deleteCollection: { + methodKind: "unary"; + input: typeof DeleteCollectionRequestSchema; + output: typeof DeleteCollectionResponseSchema; + }; + /** + * Lists all collections for the organization + * + * @generated from rpc collection.v1.DeviceCollectionService.ListCollections + */ + listCollections: { + methodKind: "unary"; + input: typeof ListCollectionsRequestSchema; + output: typeof ListCollectionsResponseSchema; + }; + /** + * Adds devices to a collection + * + * @generated from rpc collection.v1.DeviceCollectionService.AddDevicesToCollection + */ + addDevicesToCollection: { + methodKind: "unary"; + input: typeof AddDevicesToCollectionRequestSchema; + output: typeof AddDevicesToCollectionResponseSchema; + }; + /** + * Removes devices from a collection + * + * @generated from rpc collection.v1.DeviceCollectionService.RemoveDevicesFromCollection + */ + removeDevicesFromCollection: { + methodKind: "unary"; + input: typeof RemoveDevicesFromCollectionRequestSchema; + output: typeof RemoveDevicesFromCollectionResponseSchema; + }; + /** + * Lists members of a collection + * + * @generated from rpc collection.v1.DeviceCollectionService.ListCollectionMembers + */ + listCollectionMembers: { + methodKind: "unary"; + input: typeof ListCollectionMembersRequestSchema; + output: typeof ListCollectionMembersResponseSchema; + }; + /** + * Gets collections that a device belongs to + * + * @generated from rpc collection.v1.DeviceCollectionService.GetDeviceCollections + */ + getDeviceCollections: { + methodKind: "unary"; + input: typeof GetDeviceCollectionsRequestSchema; + output: typeof GetDeviceCollectionsResponseSchema; + }; + /** + * Sets a device's slot position within a rack + * + * @generated from rpc collection.v1.DeviceCollectionService.SetRackSlotPosition + */ + setRackSlotPosition: { + methodKind: "unary"; + input: typeof SetRackSlotPositionRequestSchema; + output: typeof SetRackSlotPositionResponseSchema; + }; + /** + * Clears a device's slot position within a rack + * + * @generated from rpc collection.v1.DeviceCollectionService.ClearRackSlotPosition + */ + clearRackSlotPosition: { + methodKind: "unary"; + input: typeof ClearRackSlotPositionRequestSchema; + output: typeof ClearRackSlotPositionResponseSchema; + }; + /** + * Lists all occupied slot positions in a rack + * + * @generated from rpc collection.v1.DeviceCollectionService.GetRackSlots + */ + getRackSlots: { + methodKind: "unary"; + input: typeof GetRackSlotsRequestSchema; + output: typeof GetRackSlotsResponseSchema; + }; + /** + * Returns aggregated telemetry stats for a list of collections + * + * @generated from rpc collection.v1.DeviceCollectionService.GetCollectionStats + */ + getCollectionStats: { + methodKind: "unary"; + input: typeof GetCollectionStatsRequestSchema; + output: typeof GetCollectionStatsResponseSchema; + }; + /** + * Returns all distinct rack zones for the organization + * + * @generated from rpc collection.v1.DeviceCollectionService.ListRackZones + */ + listRackZones: { + methodKind: "unary"; + input: typeof ListRackZonesRequestSchema; + output: typeof ListRackZonesResponseSchema; + }; + /** + * Returns all distinct rack types (row/column combinations) for the organization + * + * @generated from rpc collection.v1.DeviceCollectionService.ListRackTypes + */ + listRackTypes: { + methodKind: "unary"; + input: typeof ListRackTypesRequestSchema; + output: typeof ListRackTypesResponseSchema; + }; + /** + * Atomically creates or updates a rack with its membership and slot assignments. + * All operations (metadata, membership, slot positions) are applied in a single transaction. + * + * @generated from rpc collection.v1.DeviceCollectionService.SaveRack + */ + saveRack: { + methodKind: "unary"; + input: typeof SaveRackRequestSchema; + output: typeof SaveRackResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_collection_v1_collection, 0); diff --git a/client/src/protoFleet/api/generated/common/v1/common_pb.ts b/client/src/protoFleet/api/generated/common/v1/common_pb.ts new file mode 100644 index 000000000..b05233644 --- /dev/null +++ b/client/src/protoFleet/api/generated/common/v1/common_pb.ts @@ -0,0 +1,71 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file common/v1/common.proto (package common.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file common/v1/common.proto. + */ +export const file_common_v1_common: GenFile = + /*@__PURE__*/ + fileDesc( + "ChZjb21tb24vdjEvY29tbW9uLnByb3RvEgljb21tb24udjEibwoRRmxlZXRFcnJvckRldGFpbHMSKwoGY29tbW9uGAEgASgOMhkuY29tbW9uLnYxLkZsZWV0RXJyb3JDb2RlSAASEQoHc2VydmljZRgCIAEoBUgAEhIKCGVuZHBvaW50GAMgASgFSABCBgoEY29kZSoyCg5GbGVldEVycm9yQ29kZRIgChxGTEVFVF9FUlJPUl9DT0RFX1VOU1BFQ0lGSUVEEABCqAEKDWNvbS5jb21tb24udjFCC0NvbW1vblByb3RvUAFaRWdpdGh1Yi5jb20vYmxvY2svcHJvdG8tZmxlZXQvc2VydmVyL2dlbmVyYXRlZC9ncnBjL2NvbW1vbi92MTtjb21tb252MaICA0NYWKoCCUNvbW1vbi5WMcoCCUNvbW1vblxWMeICFUNvbW1vblxWMVxHUEJNZXRhZGF0YeoCCkNvbW1vbjo6VjFiBnByb3RvMw", + ); + +/** + * @generated from message common.v1.FleetErrorDetails + */ +export type FleetErrorDetails = Message<"common.v1.FleetErrorDetails"> & { + /** + * @generated from oneof common.v1.FleetErrorDetails.code + */ + code: + | { + /** + * @generated from field: common.v1.FleetErrorCode common = 1; + */ + value: FleetErrorCode; + case: "common"; + } + | { + /** + * @generated from field: int32 service = 2; + */ + value: number; + case: "service"; + } + | { + /** + * @generated from field: int32 endpoint = 3; + */ + value: number; + case: "endpoint"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message common.v1.FleetErrorDetails. + * Use `create(FleetErrorDetailsSchema)` to create a new message. + */ +export const FleetErrorDetailsSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_common_v1_common, 0); + +/** + * @generated from enum common.v1.FleetErrorCode + */ +export enum FleetErrorCode { + /** + * @generated from enum value: FLEET_ERROR_CODE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, +} + +/** + * Describes the enum common.v1.FleetErrorCode. + */ +export const FleetErrorCodeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_common_v1_common, 0); diff --git a/client/src/protoFleet/api/generated/common/v1/cooling_pb.ts b/client/src/protoFleet/api/generated/common/v1/cooling_pb.ts new file mode 100644 index 000000000..107ef8ac0 --- /dev/null +++ b/client/src/protoFleet/api/generated/common/v1/cooling_pb.ts @@ -0,0 +1,47 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file common/v1/cooling.proto (package common.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc } from "@bufbuild/protobuf/codegenv2"; + +/** + * Describes the file common/v1/cooling.proto. + */ +export const file_common_v1_cooling: GenFile = + /*@__PURE__*/ + fileDesc( + "Chdjb21tb24vdjEvY29vbGluZy5wcm90bxIJY29tbW9uLnYxKoQBCgtDb29saW5nTW9kZRIcChhDT09MSU5HX01PREVfVU5TUEVDSUZJRUQQABIbChdDT09MSU5HX01PREVfQUlSX0NPT0xFRBABEiEKHUNPT0xJTkdfTU9ERV9JTU1FUlNJT05fQ09PTEVEEAISFwoTQ09PTElOR19NT0RFX01BTlVBTBADQqkBCg1jb20uY29tbW9uLnYxQgxDb29saW5nUHJvdG9QAVpFZ2l0aHViLmNvbS9ibG9jay9wcm90by1mbGVldC9zZXJ2ZXIvZ2VuZXJhdGVkL2dycGMvY29tbW9uL3YxO2NvbW1vbnYxogIDQ1hYqgIJQ29tbW9uLlYxygIJQ29tbW9uXFYx4gIVQ29tbW9uXFYxXEdQQk1ldGFkYXRh6gIKQ29tbW9uOjpWMWIGcHJvdG8z", + ); + +/** + * CoolingMode represents the cooling configuration for a miner device. + * + * @generated from enum common.v1.CoolingMode + */ +export enum CoolingMode { + /** + * @generated from enum value: COOLING_MODE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: COOLING_MODE_AIR_COOLED = 1; + */ + AIR_COOLED = 1, + + /** + * @generated from enum value: COOLING_MODE_IMMERSION_COOLED = 2; + */ + IMMERSION_COOLED = 2, + + /** + * @generated from enum value: COOLING_MODE_MANUAL = 3; + */ + MANUAL = 3, +} + +/** + * Describes the enum common.v1.CoolingMode. + */ +export const CoolingModeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_common_v1_cooling, 0); diff --git a/client/src/protoFleet/api/generated/common/v1/device_selector_pb.ts b/client/src/protoFleet/api/generated/common/v1/device_selector_pb.ts new file mode 100644 index 000000000..862ddf26b --- /dev/null +++ b/client/src/protoFleet/api/generated/common/v1/device_selector_pb.ts @@ -0,0 +1,77 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file common/v1/device_selector.proto (package common.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file common/v1/device_selector.proto. + */ +export const file_common_v1_device_selector: GenFile = + /*@__PURE__*/ + fileDesc( + "Ch9jb21tb24vdjEvZGV2aWNlX3NlbGVjdG9yLnByb3RvEgljb21tb24udjEicQoORGV2aWNlU2VsZWN0b3ISNgoLZGV2aWNlX2xpc3QYASABKAsyHy5jb21tb24udjEuRGV2aWNlSWRlbnRpZmllckxpc3RIABIVCgthbGxfZGV2aWNlcxgCIAEoCEgAQhAKDnNlbGVjdGlvbl90eXBlIjIKFERldmljZUlkZW50aWZpZXJMaXN0EhoKEmRldmljZV9pZGVudGlmaWVycxgBIAMoCUKwAQoNY29tLmNvbW1vbi52MUITRGV2aWNlU2VsZWN0b3JQcm90b1ABWkVnaXRodWIuY29tL2Jsb2NrL3Byb3RvLWZsZWV0L3NlcnZlci9nZW5lcmF0ZWQvZ3JwYy9jb21tb24vdjE7Y29tbW9udjGiAgNDWFiqAglDb21tb24uVjHKAglDb21tb25cVjHiAhVDb21tb25cVjFcR1BCTWV0YWRhdGHqAgpDb21tb246OlYxYgZwcm90bzM", + ); + +/** + * Selects devices for cross-service operations. + * Services needing filter-based selection (e.g., fleetmanagement, minercommand) + * may define their own extended DeviceSelector with service-specific filter types. + * + * @generated from message common.v1.DeviceSelector + */ +export type DeviceSelector = Message<"common.v1.DeviceSelector"> & { + /** + * @generated from oneof common.v1.DeviceSelector.selection_type + */ + selectionType: + | { + /** + * Select specific devices by their identifiers + * + * @generated from field: common.v1.DeviceIdentifierList device_list = 1; + */ + value: DeviceIdentifierList; + case: "deviceList"; + } + | { + /** + * Select all paired devices in the organization + * + * @generated from field: bool all_devices = 2; + */ + value: boolean; + case: "allDevices"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message common.v1.DeviceSelector. + * Use `create(DeviceSelectorSchema)` to create a new message. + */ +export const DeviceSelectorSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_common_v1_device_selector, 0); + +/** + * List of device identifiers for explicit device selection + * + * @generated from message common.v1.DeviceIdentifierList + */ +export type DeviceIdentifierList = Message<"common.v1.DeviceIdentifierList"> & { + /** + * @generated from field: repeated string device_identifiers = 1; + */ + deviceIdentifiers: string[]; +}; + +/** + * Describes the message common.v1.DeviceIdentifierList. + * Use `create(DeviceIdentifierListSchema)` to create a new message. + */ +export const DeviceIdentifierListSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_common_v1_device_selector, 1); diff --git a/client/src/protoFleet/api/generated/common/v1/measurement_pb.ts b/client/src/protoFleet/api/generated/common/v1/measurement_pb.ts new file mode 100644 index 000000000..a3e678778 --- /dev/null +++ b/client/src/protoFleet/api/generated/common/v1/measurement_pb.ts @@ -0,0 +1,121 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file common/v1/measurement.proto (package common.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file common/v1/measurement.proto. + */ +export const file_common_v1_measurement: GenFile = + /*@__PURE__*/ + fileDesc( + "Chtjb21tb24vdjEvbWVhc3VyZW1lbnQucHJvdG8SCWNvbW1vbi52MSJ1CgtNZWFzdXJlbWVudBItCgl0aW1lc3RhbXAYASABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEg0KBXZhbHVlGAIgASgBEigKBHVuaXQYAyABKA4yGi5jb21tb24udjEuTWVhc3VyZW1lbnRVbml0KqICCg9NZWFzdXJlbWVudFVuaXQSIAocTUVBU1VSRU1FTlRfVU5JVF9VTlNQRUNJRklFRBAAEigKJE1FQVNVUkVNRU5UX1VOSVRfVEVSQUhBU0hfUEVSX1NFQ09ORBABEigKJE1FQVNVUkVNRU5UX1VOSVRfSk9VTEVTX1BFUl9URVJBSEFTSBACEh0KGU1FQVNVUkVNRU5UX1VOSVRfS0lMT1dBVFQQAxIcChhNRUFTVVJFTUVOVF9VTklUX0NFTFNJVVMQBBIfChtNRUFTVVJFTUVOVF9VTklUX0ZBSFJFTkhFSVQQBRIfChtNRUFTVVJFTUVOVF9VTklUX1BFUkNFTlRBR0UQBhIaChZNRUFTVVJFTUVOVF9VTklUX0hPVVJTEAdCrQEKDWNvbS5jb21tb24udjFCEE1lYXN1cmVtZW50UHJvdG9QAVpFZ2l0aHViLmNvbS9ibG9jay9wcm90by1mbGVldC9zZXJ2ZXIvZ2VuZXJhdGVkL2dycGMvY29tbW9uL3YxO2NvbW1vbnYxogIDQ1hYqgIJQ29tbW9uLlYxygIJQ29tbW9uXFYx4gIVQ29tbW9uXFYxXEdQQk1ldGFkYXRh6gIKQ29tbW9uOjpWMWIGcHJvdG8z", + [file_google_protobuf_timestamp], + ); + +/** + * A single measurement with timestamp, value, and unit + * + * @generated from message common.v1.Measurement + */ +export type Measurement = Message<"common.v1.Measurement"> & { + /** + * Timestamp of when the measurement was taken + * + * @generated from field: google.protobuf.Timestamp timestamp = 1; + */ + timestamp?: Timestamp; + + /** + * Numeric value of the measurement + * + * @generated from field: double value = 2; + */ + value: number; + + /** + * Unit of measurement + * + * @generated from field: common.v1.MeasurementUnit unit = 3; + */ + unit: MeasurementUnit; +}; + +/** + * Describes the message common.v1.Measurement. + * Use `create(MeasurementSchema)` to create a new message. + */ +export const MeasurementSchema: GenMessage = /*@__PURE__*/ messageDesc(file_common_v1_measurement, 0); + +/** + * Standard units used throughout the API + * + * @generated from enum common.v1.MeasurementUnit + */ +export enum MeasurementUnit { + /** + * Unit not specified + * + * @generated from enum value: MEASUREMENT_UNIT_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Terahash per second - for hashrate measurements + * + * @generated from enum value: MEASUREMENT_UNIT_TERAHASH_PER_SECOND = 1; + */ + TERAHASH_PER_SECOND = 1, + + /** + * Joules per terahash - for efficiency measurements + * + * @generated from enum value: MEASUREMENT_UNIT_JOULES_PER_TERAHASH = 2; + */ + JOULES_PER_TERAHASH = 2, + + /** + * Kilowatt - for power consumption measurements + * + * @generated from enum value: MEASUREMENT_UNIT_KILOWATT = 3; + */ + KILOWATT = 3, + + /** + * Degrees Celsius - for temperature measurements + * + * @generated from enum value: MEASUREMENT_UNIT_CELSIUS = 4; + */ + CELSIUS = 4, + + /** + * Degrees Fahrenheit - for temperature measurements + * + * @generated from enum value: MEASUREMENT_UNIT_FAHRENHEIT = 5; + */ + FAHRENHEIT = 5, + + /** + * Percentage - for uptime and efficiency percentages + * + * @generated from enum value: MEASUREMENT_UNIT_PERCENTAGE = 6; + */ + PERCENTAGE = 6, + + /** + * Hours - for uptime measurements + * + * @generated from enum value: MEASUREMENT_UNIT_HOURS = 7; + */ + HOURS = 7, +} + +/** + * Describes the enum common.v1.MeasurementUnit. + */ +export const MeasurementUnitSchema: GenEnum = /*@__PURE__*/ enumDesc(file_common_v1_measurement, 0); diff --git a/client/src/protoFleet/api/generated/common/v1/sort_pb.ts b/client/src/protoFleet/api/generated/common/v1/sort_pb.ts new file mode 100644 index 000000000..d64703d08 --- /dev/null +++ b/client/src/protoFleet/api/generated/common/v1/sort_pb.ts @@ -0,0 +1,189 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file common/v1/sort.proto (package common.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_buf_validate_validate } from "../../buf/validate/validate_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file common/v1/sort.proto. + */ +export const file_common_v1_sort: GenFile = + /*@__PURE__*/ + fileDesc( + "ChRjb21tb24vdjEvc29ydC5wcm90bxIJY29tbW9uLnYxInIKClNvcnRDb25maWcSLQoFZmllbGQYASABKA4yFC5jb21tb24udjEuU29ydEZpZWxkQgi6SAWCAQIQARI1CglkaXJlY3Rpb24YAiABKA4yGC5jb21tb24udjEuU29ydERpcmVjdGlvbkIIukgFggECEAEqqAMKCVNvcnRGaWVsZBIaChZTT1JUX0ZJRUxEX1VOU1BFQ0lGSUVEEAASEwoPU09SVF9GSUVMRF9OQU1FEAESGQoVU09SVF9GSUVMRF9JUF9BRERSRVNTEAISGgoWU09SVF9GSUVMRF9NQUNfQUREUkVTUxADEhQKEFNPUlRfRklFTERfTU9ERUwQBRIXChNTT1JUX0ZJRUxEX0hBU0hSQVRFEAYSGgoWU09SVF9GSUVMRF9URU1QRVJBVFVSRRAHEhQKEFNPUlRfRklFTERfUE9XRVIQCBIZChVTT1JUX0ZJRUxEX0VGRklDSUVOQ1kQCRIaChZTT1JUX0ZJRUxEX0lTU1VFX0NPVU5UEA8SFwoTU09SVF9GSUVMRF9GSVJNV0FSRRALEhsKF1NPUlRfRklFTERfREVWSUNFX0NPVU5UEAwSFwoTU09SVF9GSUVMRF9MT0NBVElPThANEhoKFlNPUlRfRklFTERfV09SS0VSX05BTUUQDiIECAQQBCIECAoQCioRU09SVF9GSUVMRF9TVEFUVVMqEVNPUlRfRklFTERfSVNTVUVTKmAKDVNvcnREaXJlY3Rpb24SHgoaU09SVF9ESVJFQ1RJT05fVU5TUEVDSUZJRUQQABIWChJTT1JUX0RJUkVDVElPTl9BU0MQARIXChNTT1JUX0RJUkVDVElPTl9ERVNDEAJCpgEKDWNvbS5jb21tb24udjFCCVNvcnRQcm90b1ABWkVnaXRodWIuY29tL2Jsb2NrL3Byb3RvLWZsZWV0L3NlcnZlci9nZW5lcmF0ZWQvZ3JwYy9jb21tb24vdjE7Y29tbW9udjGiAgNDWFiqAglDb21tb24uVjHKAglDb21tb25cVjHiAhVDb21tb25cVjFcR1BCTWV0YWRhdGHqAgpDb21tb246OlYxYgZwcm90bzM", + [file_buf_validate_validate], + ); + +/** + * Configuration for sorting list results + * + * @generated from message common.v1.SortConfig + */ +export type SortConfig = Message<"common.v1.SortConfig"> & { + /** + * Field to sort by + * + * @generated from field: common.v1.SortField field = 1; + */ + field: SortField; + + /** + * Direction to sort + * + * @generated from field: common.v1.SortDirection direction = 2; + */ + direction: SortDirection; +}; + +/** + * Describes the message common.v1.SortConfig. + * Use `create(SortConfigSchema)` to create a new message. + */ +export const SortConfigSchema: GenMessage = /*@__PURE__*/ messageDesc(file_common_v1_sort, 0); + +/** + * Field to sort by in list operations. + * Not all fields are supported by every endpoint — see individual RPCs for valid options. + * + * @generated from enum common.v1.SortField + */ +export enum SortField { + /** + * Unspecified sort field - uses default sort (name ASC) + * + * @generated from enum value: SORT_FIELD_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Sort by name/label + * + * @generated from enum value: SORT_FIELD_NAME = 1; + */ + NAME = 1, + + /** + * Sort by IP address (numeric comparison) + * + * @generated from enum value: SORT_FIELD_IP_ADDRESS = 2; + */ + IP_ADDRESS = 2, + + /** + * Sort by MAC address + * + * @generated from enum value: SORT_FIELD_MAC_ADDRESS = 3; + */ + MAC_ADDRESS = 3, + + /** + * Sort by device model (e.g., "S21 XP", "M60") + * + * @generated from enum value: SORT_FIELD_MODEL = 5; + */ + MODEL = 5, + + /** + * Sort by hashrate (TH/s) + * + * @generated from enum value: SORT_FIELD_HASHRATE = 6; + */ + HASHRATE = 6, + + /** + * Sort by temperature (Celsius) + * + * @generated from enum value: SORT_FIELD_TEMPERATURE = 7; + */ + TEMPERATURE = 7, + + /** + * Sort by power consumption (kW) + * + * @generated from enum value: SORT_FIELD_POWER = 8; + */ + POWER = 8, + + /** + * Sort by efficiency (J/TH) + * + * @generated from enum value: SORT_FIELD_EFFICIENCY = 9; + */ + EFFICIENCY = 9, + + /** + * Sort by issue count (for collection listing) + * + * @generated from enum value: SORT_FIELD_ISSUE_COUNT = 15; + */ + ISSUE_COUNT = 15, + + /** + * Sort by firmware version + * + * @generated from enum value: SORT_FIELD_FIRMWARE = 11; + */ + FIRMWARE = 11, + + /** + * Sort by device count (for collection listing) + * + * @generated from enum value: SORT_FIELD_DEVICE_COUNT = 12; + */ + DEVICE_COUNT = 12, + + /** + * Sort by location (for rack listing) + * + * @generated from enum value: SORT_FIELD_LOCATION = 13; + */ + LOCATION = 13, + + /** + * Sort by the worker name stored on fleet + * + * @generated from enum value: SORT_FIELD_WORKER_NAME = 14; + */ + WORKER_NAME = 14, +} + +/** + * Describes the enum common.v1.SortField. + */ +export const SortFieldSchema: GenEnum = /*@__PURE__*/ enumDesc(file_common_v1_sort, 0); + +/** + * Direction to sort results + * + * @generated from enum common.v1.SortDirection + */ +export enum SortDirection { + /** + * Unspecified direction - server defaults to ASC. + * + * @generated from enum value: SORT_DIRECTION_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Ascending order (A-Z, 0-9, lowest first) + * + * @generated from enum value: SORT_DIRECTION_ASC = 1; + */ + ASC = 1, + + /** + * Descending order (Z-A, 9-0, highest first) + * + * @generated from enum value: SORT_DIRECTION_DESC = 2; + */ + DESC = 2, +} + +/** + * Describes the enum common.v1.SortDirection. + */ +export const SortDirectionSchema: GenEnum = /*@__PURE__*/ enumDesc(file_common_v1_sort, 1); diff --git a/client/src/protoFleet/api/generated/device_set/v1/device_set_pb.ts b/client/src/protoFleet/api/generated/device_set/v1/device_set_pb.ts new file mode 100644 index 000000000..5396e7bf9 --- /dev/null +++ b/client/src/protoFleet/api/generated/device_set/v1/device_set_pb.ts @@ -0,0 +1,1768 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file device_set/v1/device_set.proto (package device_set.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import { file_buf_validate_validate } from "../../buf/validate/validate_pb"; +import type { DeviceSelector } from "../../common/v1/device_selector_pb"; +import { file_common_v1_device_selector } from "../../common/v1/device_selector_pb"; +import type { SortConfig } from "../../common/v1/sort_pb"; +import { file_common_v1_sort } from "../../common/v1/sort_pb"; +import type { ComponentType } from "../../errors/v1/errors_pb"; +import { file_errors_v1_errors } from "../../errors/v1/errors_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file device_set/v1/device_set.proto. + */ +export const file_device_set_v1_device_set: GenFile = + /*@__PURE__*/ + fileDesc( + "Ch5kZXZpY2Vfc2V0L3YxL2RldmljZV9zZXQucHJvdG8SDWRldmljZV9zZXQudjEiywIKCURldmljZVNldBIKCgJpZBgBIAEoAxIqCgR0eXBlGAIgASgOMhwuZGV2aWNlX3NldC52MS5EZXZpY2VTZXRUeXBlEg0KBWxhYmVsGAMgASgJEhMKC2Rlc2NyaXB0aW9uGAQgASgJEhQKDGRldmljZV9jb3VudBgFIAEoBRIuCgpjcmVhdGVkX2F0GAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgp1cGRhdGVkX2F0GAcgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIsCglyYWNrX2luZm8YCCABKAsyFy5kZXZpY2Vfc2V0LnYxLlJhY2tJbmZvSAASLgoKZ3JvdXBfaW5mbxgJIAEoCzIYLmRldmljZV9zZXQudjEuR3JvdXBJbmZvSABCDgoMdHlwZV9kZXRhaWxzIrwBCghSYWNrSW5mbxIVCgRyb3dzGAEgASgFQge6SAQaAiAAEhgKB2NvbHVtbnMYAiABKAVCB7pIBBoCIAASFQoEem9uZRgDIAEoCUIHukgEcgIQARIyCgtvcmRlcl9pbmRleBgEIAEoDjIdLmRldmljZV9zZXQudjEuUmFja09yZGVySW5kZXgSNAoMY29vbGluZ190eXBlGAUgASgOMh4uZGV2aWNlX3NldC52MS5SYWNrQ29vbGluZ1R5cGUiCwoJR3JvdXBJbmZvIp4BCg9EZXZpY2VTZXRNZW1iZXISGQoRZGV2aWNlX2lkZW50aWZpZXIYASABKAkSLAoIYWRkZWRfYXQYAiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjAKBHJhY2sYAyABKAsyIC5kZXZpY2Vfc2V0LnYxLlJhY2tNZW1iZXJEZXRhaWxzSABCEAoObWVtYmVyX2RldGFpbHMiSwoRUmFja01lbWJlckRldGFpbHMSNgoNc2xvdF9wb3NpdGlvbhgBIAEoCzIfLmRldmljZV9zZXQudjEuUmFja1Nsb3RQb3NpdGlvbiJBChBSYWNrU2xvdFBvc2l0aW9uEhQKA3JvdxgBIAEoBUIHukgEGgIoABIXCgZjb2x1bW4YAiABKAVCB7pIBBoCKAAixwIKFkNyZWF0ZURldmljZVNldFJlcXVlc3QSNgoEdHlwZRgBIAEoDjIcLmRldmljZV9zZXQudjEuRGV2aWNlU2V0VHlwZUIKukgHggEEEAEgABIbCgVsYWJlbBgCIAEoCUIMukgJyAEBcgQQARhkEh0KC2Rlc2NyaXB0aW9uGAMgASgJQgi6SAVyAxj0AxIsCglyYWNrX2luZm8YBCABKAsyFy5kZXZpY2Vfc2V0LnYxLlJhY2tJbmZvSAASLgoKZ3JvdXBfaW5mbxgFIAEoCzIYLmRldmljZV9zZXQudjEuR3JvdXBJbmZvSAASNwoPZGV2aWNlX3NlbGVjdG9yGAYgASgLMhkuY29tbW9uLnYxLkRldmljZVNlbGVjdG9ySAGIAQFCDgoMdHlwZV9kZXRhaWxzQhIKEF9kZXZpY2Vfc2VsZWN0b3IiXAoXQ3JlYXRlRGV2aWNlU2V0UmVzcG9uc2USLAoKZGV2aWNlX3NldBgBIAEoCzIYLmRldmljZV9zZXQudjEuRGV2aWNlU2V0EhMKC2FkZGVkX2NvdW50GAIgASgFIjUKE0dldERldmljZVNldFJlcXVlc3QSHgoNZGV2aWNlX3NldF9pZBgBIAEoA0IHukgEIgIgACJEChRHZXREZXZpY2VTZXRSZXNwb25zZRIsCgpkZXZpY2Vfc2V0GAEgASgLMhguZGV2aWNlX3NldC52MS5EZXZpY2VTZXQivQIKFlVwZGF0ZURldmljZVNldFJlcXVlc3QSHgoNZGV2aWNlX3NldF9pZBgBIAEoA0IHukgEIgIgABIgCgVsYWJlbBgCIAEoCUIMukgJ2AEBcgQQARhkSAGIAQESJQoLZGVzY3JpcHRpb24YAyABKAlCC7pICNgBAXIDGPQDSAKIAQESLAoJcmFja19pbmZvGAQgASgLMhcuZGV2aWNlX3NldC52MS5SYWNrSW5mb0gAEi4KCmdyb3VwX2luZm8YBSABKAsyGC5kZXZpY2Vfc2V0LnYxLkdyb3VwSW5mb0gAEjIKD2RldmljZV9zZWxlY3RvchgGIAEoCzIZLmNvbW1vbi52MS5EZXZpY2VTZWxlY3RvckIOCgx0eXBlX2RldGFpbHNCCAoGX2xhYmVsQg4KDF9kZXNjcmlwdGlvbiJHChdVcGRhdGVEZXZpY2VTZXRSZXNwb25zZRIsCgpkZXZpY2Vfc2V0GAEgASgLMhguZGV2aWNlX3NldC52MS5EZXZpY2VTZXQiOAoWRGVsZXRlRGV2aWNlU2V0UmVxdWVzdBIeCg1kZXZpY2Vfc2V0X2lkGAEgASgDQge6SAQiAiAAIhkKF0RlbGV0ZURldmljZVNldFJlc3BvbnNlIuoBChVMaXN0RGV2aWNlU2V0c1JlcXVlc3QSNAoEdHlwZRgBIAEoDjIcLmRldmljZV9zZXQudjEuRGV2aWNlU2V0VHlwZUIIukgFggECEAESGgoJcGFnZV9zaXplGAIgASgFQge6SAQaAigAEhIKCnBhZ2VfdG9rZW4YAyABKAkSIwoEc29ydBgEIAEoCzIVLmNvbW1vbi52MS5Tb3J0Q29uZmlnEjcKFWVycm9yX2NvbXBvbmVudF90eXBlcxgFIAMoDjIYLmVycm9ycy52MS5Db21wb25lbnRUeXBlEg0KBXpvbmVzGAYgAygJInUKFkxpc3REZXZpY2VTZXRzUmVzcG9uc2USLQoLZGV2aWNlX3NldHMYASADKAsyGC5kZXZpY2Vfc2V0LnYxLkRldmljZVNldBIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAkSEwoLdG90YWxfY291bnQYAyABKAUiegocQWRkRGV2aWNlc1RvRGV2aWNlU2V0UmVxdWVzdBIeCg1kZXZpY2Vfc2V0X2lkGAEgASgDQge6SAQiAiAAEjoKD2RldmljZV9zZWxlY3RvchgCIAEoCzIZLmNvbW1vbi52MS5EZXZpY2VTZWxlY3RvckIGukgDyAEBIksKHUFkZERldmljZXNUb0RldmljZVNldFJlc3BvbnNlEhUKDWRldmljZV9zZXRfaWQYASABKAMSEwoLYWRkZWRfY291bnQYAiABKAUifwohUmVtb3ZlRGV2aWNlc0Zyb21EZXZpY2VTZXRSZXF1ZXN0Eh4KDWRldmljZV9zZXRfaWQYASABKANCB7pIBCICIAASOgoPZGV2aWNlX3NlbGVjdG9yGAIgASgLMhkuY29tbW9uLnYxLkRldmljZVNlbGVjdG9yQga6SAPIAQEiOwoiUmVtb3ZlRGV2aWNlc0Zyb21EZXZpY2VTZXRSZXNwb25zZRIVCg1yZW1vdmVkX2NvdW50GAEgASgFIm0KG0xpc3REZXZpY2VTZXRNZW1iZXJzUmVxdWVzdBIeCg1kZXZpY2Vfc2V0X2lkGAEgASgDQge6SAQiAiAAEhoKCXBhZ2Vfc2l6ZRgCIAEoBUIHukgEGgIoABISCgpwYWdlX3Rva2VuGAMgASgJImgKHExpc3REZXZpY2VTZXRNZW1iZXJzUmVzcG9uc2USLwoHbWVtYmVycxgBIAMoCzIeLmRldmljZV9zZXQudjEuRGV2aWNlU2V0TWVtYmVyEhcKD25leHRfcGFnZV90b2tlbhgCIAEoCSJsChpHZXREZXZpY2VEZXZpY2VTZXRzUmVxdWVzdBIiChFkZXZpY2VfaWRlbnRpZmllchgBIAEoCUIHukgEcgIQARIqCgR0eXBlGAIgASgOMhwuZGV2aWNlX3NldC52MS5EZXZpY2VTZXRUeXBlIkwKG0dldERldmljZURldmljZVNldHNSZXNwb25zZRItCgtkZXZpY2Vfc2V0cxgBIAMoCzIYLmRldmljZV9zZXQudjEuRGV2aWNlU2V0IpsBChpTZXRSYWNrU2xvdFBvc2l0aW9uUmVxdWVzdBIeCg1kZXZpY2Vfc2V0X2lkGAEgASgDQge6SAQiAiAAEiIKEWRldmljZV9pZGVudGlmaWVyGAIgASgJQge6SARyAhABEjkKCHBvc2l0aW9uGAMgASgLMh8uZGV2aWNlX3NldC52MS5SYWNrU2xvdFBvc2l0aW9uQga6SAPIAQEiWwobU2V0UmFja1Nsb3RQb3NpdGlvblJlc3BvbnNlEhUKDWRldmljZV9zZXRfaWQYASABKAMSJQoEc2xvdBgCIAEoCzIXLmRldmljZV9zZXQudjEuUmFja1Nsb3QiYgocQ2xlYXJSYWNrU2xvdFBvc2l0aW9uUmVxdWVzdBIeCg1kZXZpY2Vfc2V0X2lkGAEgASgDQge6SAQiAiAAEiIKEWRldmljZV9pZGVudGlmaWVyGAIgASgJQge6SARyAhABIh8KHUNsZWFyUmFja1Nsb3RQb3NpdGlvblJlc3BvbnNlIjUKE0dldFJhY2tTbG90c1JlcXVlc3QSHgoNZGV2aWNlX3NldF9pZBgBIAEoA0IHukgEIgIgACJYCghSYWNrU2xvdBIZChFkZXZpY2VfaWRlbnRpZmllchgBIAEoCRIxCghwb3NpdGlvbhgCIAEoCzIfLmRldmljZV9zZXQudjEuUmFja1Nsb3RQb3NpdGlvbiI+ChRHZXRSYWNrU2xvdHNSZXNwb25zZRImCgVzbG90cxgBIAMoCzIXLmRldmljZV9zZXQudjEuUmFja1Nsb3Qi7QQKDkRldmljZVNldFN0YXRzEhUKDWRldmljZV9zZXRfaWQYASABKAMSFAoMZGV2aWNlX2NvdW50GAIgASgFEhcKD3JlcG9ydGluZ19jb3VudBgDIAEoBRIaChJ0b3RhbF9oYXNocmF0ZV90aHMYBCABKAESGgoSYXZnX2VmZmljaWVuY3lfanRoGAUgASgBEhYKDnRvdGFsX3Bvd2VyX2t3GAYgASgBEhkKEW1pbl90ZW1wZXJhdHVyZV9jGAcgASgBEhkKEW1heF90ZW1wZXJhdHVyZV9jGAggASgBEhUKDWhhc2hpbmdfY291bnQYCSABKAUSFAoMYnJva2VuX2NvdW50GAogASgFEhUKDW9mZmxpbmVfY291bnQYCyABKAUSFgoOc2xlZXBpbmdfY291bnQYDCABKAUSIAoYaGFzaHJhdGVfcmVwb3J0aW5nX2NvdW50GA0gASgFEiIKGmVmZmljaWVuY3lfcmVwb3J0aW5nX2NvdW50GA4gASgFEh0KFXBvd2VyX3JlcG9ydGluZ19jb3VudBgPIAEoBRIjCht0ZW1wZXJhdHVyZV9yZXBvcnRpbmdfY291bnQYECABKAUSIQoZY29udHJvbF9ib2FyZF9pc3N1ZV9jb3VudBgRIAEoBRIXCg9mYW5faXNzdWVfY291bnQYEiABKAUSHgoWaGFzaF9ib2FyZF9pc3N1ZV9jb3VudBgTIAEoBRIXCg9wc3VfaXNzdWVfY291bnQYFCABKAUSNAoNc2xvdF9zdGF0dXNlcxgVIAMoCzIdLmRldmljZV9zZXQudjEuUmFja1Nsb3RTdGF0dXMiMgoYR2V0RGV2aWNlU2V0U3RhdHNSZXF1ZXN0EhYKDmRldmljZV9zZXRfaWRzGAEgAygDIkkKGUdldERldmljZVNldFN0YXRzUmVzcG9uc2USLAoFc3RhdHMYASADKAsyHS5kZXZpY2Vfc2V0LnYxLkRldmljZVNldFN0YXRzIl4KDlJhY2tTbG90U3RhdHVzEgsKA3JvdxgBIAEoBRIOCgZjb2x1bW4YAiABKAUSLwoGc3RhdHVzGAMgASgOMh8uZGV2aWNlX3NldC52MS5TbG90RGV2aWNlU3RhdHVzIhYKFExpc3RSYWNrWm9uZXNSZXF1ZXN0IiYKFUxpc3RSYWNrWm9uZXNSZXNwb25zZRINCgV6b25lcxgBIAMoCSIWChRMaXN0UmFja1R5cGVzUmVxdWVzdCI9CghSYWNrVHlwZRIMCgRyb3dzGAEgASgFEg8KB2NvbHVtbnMYAiABKAUSEgoKcmFja19jb3VudBgDIAEoBSJEChVMaXN0UmFja1R5cGVzUmVzcG9uc2USKwoKcmFja190eXBlcxgBIAMoCzIXLmRldmljZV9zZXQudjEuUmFja1R5cGUiiwIKD1NhdmVSYWNrUmVxdWVzdBImCg1kZXZpY2Vfc2V0X2lkGAEgASgDQgq6SAfYAQEiAiAASACIAQESGwoFbGFiZWwYAiABKAlCDLpICcgBAXIEEAEYZBIyCglyYWNrX2luZm8YAyABKAsyFy5kZXZpY2Vfc2V0LnYxLlJhY2tJbmZvQga6SAPIAQESOgoPZGV2aWNlX3NlbGVjdG9yGAQgASgLMhkuY29tbW9uLnYxLkRldmljZVNlbGVjdG9yQga6SAPIAQESMQoQc2xvdF9hc3NpZ25tZW50cxgFIAMoCzIXLmRldmljZV9zZXQudjEuUmFja1Nsb3RCEAoOX2RldmljZV9zZXRfaWQiWAoQU2F2ZVJhY2tSZXNwb25zZRIsCgpkZXZpY2Vfc2V0GAEgASgLMhguZGV2aWNlX3NldC52MS5EZXZpY2VTZXQSFgoOYXNzaWduZWRfY291bnQYAiABKAUqZQoNRGV2aWNlU2V0VHlwZRIfChtERVZJQ0VfU0VUX1RZUEVfVU5TUEVDSUZJRUQQABIZChVERVZJQ0VfU0VUX1RZUEVfR1JPVVAQARIYChRERVZJQ0VfU0VUX1RZUEVfUkFDSxACKrYBCg5SYWNrT3JkZXJJbmRleBIgChxSQUNLX09SREVSX0lOREVYX1VOU1BFQ0lGSUVEEAASIAocUkFDS19PUkRFUl9JTkRFWF9CT1RUT01fTEVGVBABEh0KGVJBQ0tfT1JERVJfSU5ERVhfVE9QX0xFRlQQAhIhCh1SQUNLX09SREVSX0lOREVYX0JPVFRPTV9SSUdIVBADEh4KGlJBQ0tfT1JERVJfSU5ERVhfVE9QX1JJR0hUEAQqcAoPUmFja0Nvb2xpbmdUeXBlEiEKHVJBQ0tfQ09PTElOR19UWVBFX1VOU1BFQ0lGSUVEEAASGQoVUkFDS19DT09MSU5HX1RZUEVfQUlSEAESHwobUkFDS19DT09MSU5HX1RZUEVfSU1NRVJTSU9OEAIq3QEKEFNsb3REZXZpY2VTdGF0dXMSIgoeU0xPVF9ERVZJQ0VfU1RBVFVTX1VOU1BFQ0lGSUVEEAASHAoYU0xPVF9ERVZJQ0VfU1RBVFVTX0VNUFRZEAESHgoaU0xPVF9ERVZJQ0VfU1RBVFVTX0hFQUxUSFkQAhImCiJTTE9UX0RFVklDRV9TVEFUVVNfTkVFRFNfQVRURU5USU9OEAMSHgoaU0xPVF9ERVZJQ0VfU1RBVFVTX09GRkxJTkUQBBIfChtTTE9UX0RFVklDRV9TVEFUVVNfU0xFRVBJTkcQBTLvDAoQRGV2aWNlU2V0U2VydmljZRJgCg9DcmVhdGVEZXZpY2VTZXQSJS5kZXZpY2Vfc2V0LnYxLkNyZWF0ZURldmljZVNldFJlcXVlc3QaJi5kZXZpY2Vfc2V0LnYxLkNyZWF0ZURldmljZVNldFJlc3BvbnNlElcKDEdldERldmljZVNldBIiLmRldmljZV9zZXQudjEuR2V0RGV2aWNlU2V0UmVxdWVzdBojLmRldmljZV9zZXQudjEuR2V0RGV2aWNlU2V0UmVzcG9uc2USYAoPVXBkYXRlRGV2aWNlU2V0EiUuZGV2aWNlX3NldC52MS5VcGRhdGVEZXZpY2VTZXRSZXF1ZXN0GiYuZGV2aWNlX3NldC52MS5VcGRhdGVEZXZpY2VTZXRSZXNwb25zZRJgCg9EZWxldGVEZXZpY2VTZXQSJS5kZXZpY2Vfc2V0LnYxLkRlbGV0ZURldmljZVNldFJlcXVlc3QaJi5kZXZpY2Vfc2V0LnYxLkRlbGV0ZURldmljZVNldFJlc3BvbnNlEl0KDkxpc3REZXZpY2VTZXRzEiQuZGV2aWNlX3NldC52MS5MaXN0RGV2aWNlU2V0c1JlcXVlc3QaJS5kZXZpY2Vfc2V0LnYxLkxpc3REZXZpY2VTZXRzUmVzcG9uc2UScgoVQWRkRGV2aWNlc1RvRGV2aWNlU2V0EisuZGV2aWNlX3NldC52MS5BZGREZXZpY2VzVG9EZXZpY2VTZXRSZXF1ZXN0GiwuZGV2aWNlX3NldC52MS5BZGREZXZpY2VzVG9EZXZpY2VTZXRSZXNwb25zZRKBAQoaUmVtb3ZlRGV2aWNlc0Zyb21EZXZpY2VTZXQSMC5kZXZpY2Vfc2V0LnYxLlJlbW92ZURldmljZXNGcm9tRGV2aWNlU2V0UmVxdWVzdBoxLmRldmljZV9zZXQudjEuUmVtb3ZlRGV2aWNlc0Zyb21EZXZpY2VTZXRSZXNwb25zZRJvChRMaXN0RGV2aWNlU2V0TWVtYmVycxIqLmRldmljZV9zZXQudjEuTGlzdERldmljZVNldE1lbWJlcnNSZXF1ZXN0GisuZGV2aWNlX3NldC52MS5MaXN0RGV2aWNlU2V0TWVtYmVyc1Jlc3BvbnNlEmwKE0dldERldmljZURldmljZVNldHMSKS5kZXZpY2Vfc2V0LnYxLkdldERldmljZURldmljZVNldHNSZXF1ZXN0GiouZGV2aWNlX3NldC52MS5HZXREZXZpY2VEZXZpY2VTZXRzUmVzcG9uc2USbAoTU2V0UmFja1Nsb3RQb3NpdGlvbhIpLmRldmljZV9zZXQudjEuU2V0UmFja1Nsb3RQb3NpdGlvblJlcXVlc3QaKi5kZXZpY2Vfc2V0LnYxLlNldFJhY2tTbG90UG9zaXRpb25SZXNwb25zZRJyChVDbGVhclJhY2tTbG90UG9zaXRpb24SKy5kZXZpY2Vfc2V0LnYxLkNsZWFyUmFja1Nsb3RQb3NpdGlvblJlcXVlc3QaLC5kZXZpY2Vfc2V0LnYxLkNsZWFyUmFja1Nsb3RQb3NpdGlvblJlc3BvbnNlElcKDEdldFJhY2tTbG90cxIiLmRldmljZV9zZXQudjEuR2V0UmFja1Nsb3RzUmVxdWVzdBojLmRldmljZV9zZXQudjEuR2V0UmFja1Nsb3RzUmVzcG9uc2USZgoRR2V0RGV2aWNlU2V0U3RhdHMSJy5kZXZpY2Vfc2V0LnYxLkdldERldmljZVNldFN0YXRzUmVxdWVzdBooLmRldmljZV9zZXQudjEuR2V0RGV2aWNlU2V0U3RhdHNSZXNwb25zZRJaCg1MaXN0UmFja1pvbmVzEiMuZGV2aWNlX3NldC52MS5MaXN0UmFja1pvbmVzUmVxdWVzdBokLmRldmljZV9zZXQudjEuTGlzdFJhY2tab25lc1Jlc3BvbnNlEloKDUxpc3RSYWNrVHlwZXMSIy5kZXZpY2Vfc2V0LnYxLkxpc3RSYWNrVHlwZXNSZXF1ZXN0GiQuZGV2aWNlX3NldC52MS5MaXN0UmFja1R5cGVzUmVzcG9uc2USSwoIU2F2ZVJhY2sSHi5kZXZpY2Vfc2V0LnYxLlNhdmVSYWNrUmVxdWVzdBofLmRldmljZV9zZXQudjEuU2F2ZVJhY2tSZXNwb25zZULDAQoRY29tLmRldmljZV9zZXQudjFCDkRldmljZVNldFByb3RvUAFaTWdpdGh1Yi5jb20vYmxvY2svcHJvdG8tZmxlZXQvc2VydmVyL2dlbmVyYXRlZC9ncnBjL2RldmljZV9zZXQvdjE7ZGV2aWNlX3NldHYxogIDRFhYqgIMRGV2aWNlU2V0LlYxygIMRGV2aWNlU2V0XFYx4gIYRGV2aWNlU2V0XFYxXEdQQk1ldGFkYXRh6gINRGV2aWNlU2V0OjpWMWIGcHJvdG8z", + [ + file_google_protobuf_timestamp, + file_buf_validate_validate, + file_common_v1_device_selector, + file_common_v1_sort, + file_errors_v1_errors, + ], + ); + +/** + * DeviceSet represents a group or rack of devices + * + * @generated from message device_set.v1.DeviceSet + */ +export type DeviceSet = Message<"device_set.v1.DeviceSet"> & { + /** + * Unique identifier for the device set + * + * @generated from field: int64 id = 1; + */ + id: bigint; + + /** + * Type of device set (group or rack) + * + * @generated from field: device_set.v1.DeviceSetType type = 2; + */ + type: DeviceSetType; + + /** + * Human-readable label for the device set + * + * @generated from field: string label = 3; + */ + label: string; + + /** + * Optional description of the device set's purpose + * + * @generated from field: string description = 4; + */ + description: string; + + /** + * Number of devices in this device set + * + * @generated from field: int32 device_count = 5; + */ + deviceCount: number; + + /** + * When the device set was created + * + * @generated from field: google.protobuf.Timestamp created_at = 6; + */ + createdAt?: Timestamp; + + /** + * When the device set was last updated + * + * @generated from field: google.protobuf.Timestamp updated_at = 7; + */ + updatedAt?: Timestamp; + + /** + * Type-specific metadata (enforces only one detail type is set) + * + * @generated from oneof device_set.v1.DeviceSet.type_details + */ + typeDetails: + | { + /** + * @generated from field: device_set.v1.RackInfo rack_info = 8; + */ + value: RackInfo; + case: "rackInfo"; + } + | { + /** + * @generated from field: device_set.v1.GroupInfo group_info = 9; + */ + value: GroupInfo; + case: "groupInfo"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message device_set.v1.DeviceSet. + * Use `create(DeviceSetSchema)` to create a new message. + */ +export const DeviceSetSchema: GenMessage = /*@__PURE__*/ messageDesc(file_device_set_v1_device_set, 0); + +/** + * Rack-specific metadata for rack-type device sets + * + * @generated from message device_set.v1.RackInfo + */ +export type RackInfo = Message<"device_set.v1.RackInfo"> & { + /** + * Number of rows in the rack grid + * + * @generated from field: int32 rows = 1; + */ + rows: number; + + /** + * Number of columns in the rack grid + * + * @generated from field: int32 columns = 2; + */ + columns: number; + + /** + * Physical zone description (e.g. building, room, area) + * + * @generated from field: string zone = 3; + */ + zone: string; + + /** + * Order index defining where numbering starts + * + * @generated from field: device_set.v1.RackOrderIndex order_index = 4; + */ + orderIndex: RackOrderIndex; + + /** + * Cooling type for this rack + * + * @generated from field: device_set.v1.RackCoolingType cooling_type = 5; + */ + coolingType: RackCoolingType; +}; + +/** + * Describes the message device_set.v1.RackInfo. + * Use `create(RackInfoSchema)` to create a new message. + */ +export const RackInfoSchema: GenMessage = /*@__PURE__*/ messageDesc(file_device_set_v1_device_set, 1); + +/** + * Group-specific metadata for group-type device sets + * Reserved for future group-specific fields (e.g., tags, policies) + * + * @generated from message device_set.v1.GroupInfo + */ +export type GroupInfo = Message<"device_set.v1.GroupInfo"> & {}; + +/** + * Describes the message device_set.v1.GroupInfo. + * Use `create(GroupInfoSchema)` to create a new message. + */ +export const GroupInfoSchema: GenMessage = /*@__PURE__*/ messageDesc(file_device_set_v1_device_set, 2); + +/** + * DeviceSetMember represents a device in a device set + * + * @generated from message device_set.v1.DeviceSetMember + */ +export type DeviceSetMember = Message<"device_set.v1.DeviceSetMember"> & { + /** + * Device identifier of the member + * + * @generated from field: string device_identifier = 1; + */ + deviceIdentifier: string; + + /** + * When the device was added to the device set + * + * @generated from field: google.protobuf.Timestamp added_at = 2; + */ + addedAt?: Timestamp; + + /** + * Type-specific member details + * + * @generated from oneof device_set.v1.DeviceSetMember.member_details + */ + memberDetails: + | { + /** + * @generated from field: device_set.v1.RackMemberDetails rack = 3; + */ + value: RackMemberDetails; + case: "rack"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message device_set.v1.DeviceSetMember. + * Use `create(DeviceSetMemberSchema)` to create a new message. + */ +export const DeviceSetMemberSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 3); + +/** + * Rack-specific details for a device set member + * + * @generated from message device_set.v1.RackMemberDetails + */ +export type RackMemberDetails = Message<"device_set.v1.RackMemberDetails"> & { + /** + * Slot position of the device within the rack + * + * @generated from field: device_set.v1.RackSlotPosition slot_position = 1; + */ + slotPosition?: RackSlotPosition; +}; + +/** + * Describes the message device_set.v1.RackMemberDetails. + * Use `create(RackMemberDetailsSchema)` to create a new message. + */ +export const RackMemberDetailsSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 4); + +/** + * Position of a device within a rack + * + * @generated from message device_set.v1.RackSlotPosition + */ +export type RackSlotPosition = Message<"device_set.v1.RackSlotPosition"> & { + /** + * Row position (0-indexed) + * + * @generated from field: int32 row = 1; + */ + row: number; + + /** + * Column position (0-indexed) + * + * @generated from field: int32 column = 2; + */ + column: number; +}; + +/** + * Describes the message device_set.v1.RackSlotPosition. + * Use `create(RackSlotPositionSchema)` to create a new message. + */ +export const RackSlotPositionSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 5); + +/** + * Request to create a new device set + * + * @generated from message device_set.v1.CreateDeviceSetRequest + */ +export type CreateDeviceSetRequest = Message<"device_set.v1.CreateDeviceSetRequest"> & { + /** + * Type of device set to create (required) + * + * @generated from field: device_set.v1.DeviceSetType type = 1; + */ + type: DeviceSetType; + + /** + * Label for the device set (required, 1-100 characters) + * + * @generated from field: string label = 2; + */ + label: string; + + /** + * Optional description (max 500 characters) + * + * @generated from field: string description = 3; + */ + description: string; + + /** + * Type-specific metadata (rack_info required for racks, group_info optional for groups) + * + * @generated from oneof device_set.v1.CreateDeviceSetRequest.type_details + */ + typeDetails: + | { + /** + * @generated from field: device_set.v1.RackInfo rack_info = 4; + */ + value: RackInfo; + case: "rackInfo"; + } + | { + /** + * @generated from field: device_set.v1.GroupInfo group_info = 5; + */ + value: GroupInfo; + case: "groupInfo"; + } + | { case: undefined; value?: undefined }; + + /** + * Optional: devices to add atomically when creating the device set. + * If provided, devices are added in the same transaction as device set creation. + * + * @generated from field: optional common.v1.DeviceSelector device_selector = 6; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message device_set.v1.CreateDeviceSetRequest. + * Use `create(CreateDeviceSetRequestSchema)` to create a new message. + */ +export const CreateDeviceSetRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 6); + +/** + * Response after creating a device set + * + * @generated from message device_set.v1.CreateDeviceSetResponse + */ +export type CreateDeviceSetResponse = Message<"device_set.v1.CreateDeviceSetResponse"> & { + /** + * The newly created device set + * + * @generated from field: device_set.v1.DeviceSet device_set = 1; + */ + deviceSet?: DeviceSet; + + /** + * Number of devices added to the device set (0 if no device_selector was provided) + * + * @generated from field: int32 added_count = 2; + */ + addedCount: number; +}; + +/** + * Describes the message device_set.v1.CreateDeviceSetResponse. + * Use `create(CreateDeviceSetResponseSchema)` to create a new message. + */ +export const CreateDeviceSetResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 7); + +/** + * Request to get a device set by ID + * + * @generated from message device_set.v1.GetDeviceSetRequest + */ +export type GetDeviceSetRequest = Message<"device_set.v1.GetDeviceSetRequest"> & { + /** + * ID of the device set to retrieve + * + * @generated from field: int64 device_set_id = 1; + */ + deviceSetId: bigint; +}; + +/** + * Describes the message device_set.v1.GetDeviceSetRequest. + * Use `create(GetDeviceSetRequestSchema)` to create a new message. + */ +export const GetDeviceSetRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 8); + +/** + * Response containing the requested device set + * + * @generated from message device_set.v1.GetDeviceSetResponse + */ +export type GetDeviceSetResponse = Message<"device_set.v1.GetDeviceSetResponse"> & { + /** + * The requested device set + * + * @generated from field: device_set.v1.DeviceSet device_set = 1; + */ + deviceSet?: DeviceSet; +}; + +/** + * Describes the message device_set.v1.GetDeviceSetResponse. + * Use `create(GetDeviceSetResponseSchema)` to create a new message. + */ +export const GetDeviceSetResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 9); + +/** + * Request to update a device set + * + * @generated from message device_set.v1.UpdateDeviceSetRequest + */ +export type UpdateDeviceSetRequest = Message<"device_set.v1.UpdateDeviceSetRequest"> & { + /** + * ID of the device set to update + * + * @generated from field: int64 device_set_id = 1; + */ + deviceSetId: bigint; + + /** + * New label (optional, 1-100 characters if provided) + * + * @generated from field: optional string label = 2; + */ + label?: string; + + /** + * New description (optional, max 500 characters if provided). + * Omit the field to leave unchanged; set to empty string to clear. + * + * @generated from field: optional string description = 3; + */ + description?: string; + + /** + * Type-specific metadata updates (only applicable for the device set's type) + * + * @generated from oneof device_set.v1.UpdateDeviceSetRequest.type_details + */ + typeDetails: + | { + /** + * @generated from field: device_set.v1.RackInfo rack_info = 4; + */ + value: RackInfo; + case: "rackInfo"; + } + | { + /** + * @generated from field: device_set.v1.GroupInfo group_info = 5; + */ + value: GroupInfo; + case: "groupInfo"; + } + | { case: undefined; value?: undefined }; + + /** + * Optional: atomically replace all device set members with the selected devices. + * + * @generated from field: common.v1.DeviceSelector device_selector = 6; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message device_set.v1.UpdateDeviceSetRequest. + * Use `create(UpdateDeviceSetRequestSchema)` to create a new message. + */ +export const UpdateDeviceSetRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 10); + +/** + * Response after updating a device set + * + * @generated from message device_set.v1.UpdateDeviceSetResponse + */ +export type UpdateDeviceSetResponse = Message<"device_set.v1.UpdateDeviceSetResponse"> & { + /** + * The updated device set + * + * @generated from field: device_set.v1.DeviceSet device_set = 1; + */ + deviceSet?: DeviceSet; +}; + +/** + * Describes the message device_set.v1.UpdateDeviceSetResponse. + * Use `create(UpdateDeviceSetResponseSchema)` to create a new message. + */ +export const UpdateDeviceSetResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 11); + +/** + * Request to delete a device set + * + * @generated from message device_set.v1.DeleteDeviceSetRequest + */ +export type DeleteDeviceSetRequest = Message<"device_set.v1.DeleteDeviceSetRequest"> & { + /** + * ID of the device set to delete + * + * @generated from field: int64 device_set_id = 1; + */ + deviceSetId: bigint; +}; + +/** + * Describes the message device_set.v1.DeleteDeviceSetRequest. + * Use `create(DeleteDeviceSetRequestSchema)` to create a new message. + */ +export const DeleteDeviceSetRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 12); + +/** + * Response after deleting a device set + * + * Empty response - success indicated by gRPC status + * + * @generated from message device_set.v1.DeleteDeviceSetResponse + */ +export type DeleteDeviceSetResponse = Message<"device_set.v1.DeleteDeviceSetResponse"> & {}; + +/** + * Describes the message device_set.v1.DeleteDeviceSetResponse. + * Use `create(DeleteDeviceSetResponseSchema)` to create a new message. + */ +export const DeleteDeviceSetResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 13); + +/** + * Request to list all device sets + * + * @generated from message device_set.v1.ListDeviceSetsRequest + */ +export type ListDeviceSetsRequest = Message<"device_set.v1.ListDeviceSetsRequest"> & { + /** + * Filter by device set type (optional, returns all types if unspecified) + * + * @generated from field: device_set.v1.DeviceSetType type = 1; + */ + type: DeviceSetType; + + /** + * Maximum number of device sets to return (0 = server default) + * + * @generated from field: int32 page_size = 2; + */ + pageSize: number; + + /** + * Pagination cursor from a previous response + * + * @generated from field: string page_token = 3; + */ + pageToken: string; + + /** + * Sort configuration (defaults to name ascending). + * Supported fields: SORT_FIELD_NAME, SORT_FIELD_DEVICE_COUNT, SORT_FIELD_ISSUE_COUNT. + * + * @generated from field: common.v1.SortConfig sort = 4; + */ + sort?: SortConfig; + + /** + * Filter by device sets containing devices with open errors of these component types. + * When non-empty, only device sets with at least one device having an open error + * matching any of the specified component types are returned. + * + * @generated from field: repeated errors.v1.ComponentType error_component_types = 5; + */ + errorComponentTypes: ComponentType[]; + + /** + * Filter by rack zones. Only valid when type is RACK. + * When non-empty, only racks in any of the specified zones are returned. + * + * @generated from field: repeated string zones = 6; + */ + zones: string[]; +}; + +/** + * Describes the message device_set.v1.ListDeviceSetsRequest. + * Use `create(ListDeviceSetsRequestSchema)` to create a new message. + */ +export const ListDeviceSetsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 14); + +/** + * Response containing device sets + * + * @generated from message device_set.v1.ListDeviceSetsResponse + */ +export type ListDeviceSetsResponse = Message<"device_set.v1.ListDeviceSetsResponse"> & { + /** + * List of device sets ordered by label + * + * @generated from field: repeated device_set.v1.DeviceSet device_sets = 1; + */ + deviceSets: DeviceSet[]; + + /** + * Cursor for the next page, empty if no more results + * + * @generated from field: string next_page_token = 2; + */ + nextPageToken: string; + + /** + * Total number of device sets matching the request filters + * + * @generated from field: int32 total_count = 3; + */ + totalCount: number; +}; + +/** + * Describes the message device_set.v1.ListDeviceSetsResponse. + * Use `create(ListDeviceSetsResponseSchema)` to create a new message. + */ +export const ListDeviceSetsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 15); + +/** + * Request to add devices to a device set + * + * @generated from message device_set.v1.AddDevicesToDeviceSetRequest + */ +export type AddDevicesToDeviceSetRequest = Message<"device_set.v1.AddDevicesToDeviceSetRequest"> & { + /** + * ID of the device set to add devices to + * + * @generated from field: int64 device_set_id = 1; + */ + deviceSetId: bigint; + + /** + * Devices to add: specific list or all paired devices + * + * @generated from field: common.v1.DeviceSelector device_selector = 2; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message device_set.v1.AddDevicesToDeviceSetRequest. + * Use `create(AddDevicesToDeviceSetRequestSchema)` to create a new message. + */ +export const AddDevicesToDeviceSetRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 16); + +/** + * Response after adding devices to a device set + * + * @generated from message device_set.v1.AddDevicesToDeviceSetResponse + */ +export type AddDevicesToDeviceSetResponse = Message<"device_set.v1.AddDevicesToDeviceSetResponse"> & { + /** + * ID of the device set devices were added to + * + * @generated from field: int64 device_set_id = 1; + */ + deviceSetId: bigint; + + /** + * Number of devices successfully added + * May be less than requested if some devices were already members + * + * @generated from field: int32 added_count = 2; + */ + addedCount: number; +}; + +/** + * Describes the message device_set.v1.AddDevicesToDeviceSetResponse. + * Use `create(AddDevicesToDeviceSetResponseSchema)` to create a new message. + */ +export const AddDevicesToDeviceSetResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 17); + +/** + * Request to remove devices from a device set + * + * @generated from message device_set.v1.RemoveDevicesFromDeviceSetRequest + */ +export type RemoveDevicesFromDeviceSetRequest = Message<"device_set.v1.RemoveDevicesFromDeviceSetRequest"> & { + /** + * ID of the device set to remove devices from + * + * @generated from field: int64 device_set_id = 1; + */ + deviceSetId: bigint; + + /** + * Devices to remove: specific list or all paired devices + * + * @generated from field: common.v1.DeviceSelector device_selector = 2; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message device_set.v1.RemoveDevicesFromDeviceSetRequest. + * Use `create(RemoveDevicesFromDeviceSetRequestSchema)` to create a new message. + */ +export const RemoveDevicesFromDeviceSetRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 18); + +/** + * Response after removing devices from a device set + * + * @generated from message device_set.v1.RemoveDevicesFromDeviceSetResponse + */ +export type RemoveDevicesFromDeviceSetResponse = Message<"device_set.v1.RemoveDevicesFromDeviceSetResponse"> & { + /** + * Number of devices successfully removed + * + * @generated from field: int32 removed_count = 1; + */ + removedCount: number; +}; + +/** + * Describes the message device_set.v1.RemoveDevicesFromDeviceSetResponse. + * Use `create(RemoveDevicesFromDeviceSetResponseSchema)` to create a new message. + */ +export const RemoveDevicesFromDeviceSetResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 19); + +/** + * Request to list members of a device set + * + * @generated from message device_set.v1.ListDeviceSetMembersRequest + */ +export type ListDeviceSetMembersRequest = Message<"device_set.v1.ListDeviceSetMembersRequest"> & { + /** + * ID of the device set to list members for + * + * @generated from field: int64 device_set_id = 1; + */ + deviceSetId: bigint; + + /** + * Maximum number of members to return (default: all) + * + * @generated from field: int32 page_size = 2; + */ + pageSize: number; + + /** + * Pagination cursor from a previous response + * + * @generated from field: string page_token = 3; + */ + pageToken: string; +}; + +/** + * Describes the message device_set.v1.ListDeviceSetMembersRequest. + * Use `create(ListDeviceSetMembersRequestSchema)` to create a new message. + */ +export const ListDeviceSetMembersRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 20); + +/** + * Response containing device set members + * + * @generated from message device_set.v1.ListDeviceSetMembersResponse + */ +export type ListDeviceSetMembersResponse = Message<"device_set.v1.ListDeviceSetMembersResponse"> & { + /** + * List of members ordered by when they were added (newest first) + * + * @generated from field: repeated device_set.v1.DeviceSetMember members = 1; + */ + members: DeviceSetMember[]; + + /** + * Cursor for the next page, empty if no more results + * + * @generated from field: string next_page_token = 2; + */ + nextPageToken: string; +}; + +/** + * Describes the message device_set.v1.ListDeviceSetMembersResponse. + * Use `create(ListDeviceSetMembersResponseSchema)` to create a new message. + */ +export const ListDeviceSetMembersResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 21); + +/** + * Request to get device sets for a device + * + * @generated from message device_set.v1.GetDeviceDeviceSetsRequest + */ +export type GetDeviceDeviceSetsRequest = Message<"device_set.v1.GetDeviceDeviceSetsRequest"> & { + /** + * Device identifier to look up + * + * @generated from field: string device_identifier = 1; + */ + deviceIdentifier: string; + + /** + * Filter by device set type (optional, returns all types if unspecified) + * + * @generated from field: device_set.v1.DeviceSetType type = 2; + */ + type: DeviceSetType; +}; + +/** + * Describes the message device_set.v1.GetDeviceDeviceSetsRequest. + * Use `create(GetDeviceDeviceSetsRequestSchema)` to create a new message. + */ +export const GetDeviceDeviceSetsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 22); + +/** + * Response containing device sets the device belongs to + * + * @generated from message device_set.v1.GetDeviceDeviceSetsResponse + */ +export type GetDeviceDeviceSetsResponse = Message<"device_set.v1.GetDeviceDeviceSetsResponse"> & { + /** + * Device sets the device belongs to, ordered by label + * + * @generated from field: repeated device_set.v1.DeviceSet device_sets = 1; + */ + deviceSets: DeviceSet[]; +}; + +/** + * Describes the message device_set.v1.GetDeviceDeviceSetsResponse. + * Use `create(GetDeviceDeviceSetsResponseSchema)` to create a new message. + */ +export const GetDeviceDeviceSetsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 23); + +/** + * Request to set a device's slot position within a rack + * + * @generated from message device_set.v1.SetRackSlotPositionRequest + */ +export type SetRackSlotPositionRequest = Message<"device_set.v1.SetRackSlotPositionRequest"> & { + /** + * ID of the rack device set + * + * @generated from field: int64 device_set_id = 1; + */ + deviceSetId: bigint; + + /** + * Device to position + * + * @generated from field: string device_identifier = 2; + */ + deviceIdentifier: string; + + /** + * Target slot position + * + * @generated from field: device_set.v1.RackSlotPosition position = 3; + */ + position?: RackSlotPosition; +}; + +/** + * Describes the message device_set.v1.SetRackSlotPositionRequest. + * Use `create(SetRackSlotPositionRequestSchema)` to create a new message. + */ +export const SetRackSlotPositionRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 24); + +/** + * Response after setting a rack slot position + * + * @generated from message device_set.v1.SetRackSlotPositionResponse + */ +export type SetRackSlotPositionResponse = Message<"device_set.v1.SetRackSlotPositionResponse"> & { + /** + * ID of the rack device set + * + * @generated from field: int64 device_set_id = 1; + */ + deviceSetId: bigint; + + /** + * The slot that was set + * + * @generated from field: device_set.v1.RackSlot slot = 2; + */ + slot?: RackSlot; +}; + +/** + * Describes the message device_set.v1.SetRackSlotPositionResponse. + * Use `create(SetRackSlotPositionResponseSchema)` to create a new message. + */ +export const SetRackSlotPositionResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 25); + +/** + * Request to clear a device's slot position within a rack + * + * @generated from message device_set.v1.ClearRackSlotPositionRequest + */ +export type ClearRackSlotPositionRequest = Message<"device_set.v1.ClearRackSlotPositionRequest"> & { + /** + * ID of the rack device set + * + * @generated from field: int64 device_set_id = 1; + */ + deviceSetId: bigint; + + /** + * Device to unposition + * + * @generated from field: string device_identifier = 2; + */ + deviceIdentifier: string; +}; + +/** + * Describes the message device_set.v1.ClearRackSlotPositionRequest. + * Use `create(ClearRackSlotPositionRequestSchema)` to create a new message. + */ +export const ClearRackSlotPositionRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 26); + +/** + * Response after clearing a rack slot position + * + * @generated from message device_set.v1.ClearRackSlotPositionResponse + */ +export type ClearRackSlotPositionResponse = Message<"device_set.v1.ClearRackSlotPositionResponse"> & {}; + +/** + * Describes the message device_set.v1.ClearRackSlotPositionResponse. + * Use `create(ClearRackSlotPositionResponseSchema)` to create a new message. + */ +export const ClearRackSlotPositionResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 27); + +/** + * Request to list all occupied slots in a rack + * + * @generated from message device_set.v1.GetRackSlotsRequest + */ +export type GetRackSlotsRequest = Message<"device_set.v1.GetRackSlotsRequest"> & { + /** + * ID of the rack device set + * + * @generated from field: int64 device_set_id = 1; + */ + deviceSetId: bigint; +}; + +/** + * Describes the message device_set.v1.GetRackSlotsRequest. + * Use `create(GetRackSlotsRequestSchema)` to create a new message. + */ +export const GetRackSlotsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 28); + +/** + * Represents a device assigned to a specific slot in a rack + * + * @generated from message device_set.v1.RackSlot + */ +export type RackSlot = Message<"device_set.v1.RackSlot"> & { + /** + * Device in this slot + * + * @generated from field: string device_identifier = 1; + */ + deviceIdentifier: string; + + /** + * Slot position within the rack + * + * @generated from field: device_set.v1.RackSlotPosition position = 2; + */ + position?: RackSlotPosition; +}; + +/** + * Describes the message device_set.v1.RackSlot. + * Use `create(RackSlotSchema)` to create a new message. + */ +export const RackSlotSchema: GenMessage = /*@__PURE__*/ messageDesc(file_device_set_v1_device_set, 29); + +/** + * Response containing all occupied rack slots + * + * @generated from message device_set.v1.GetRackSlotsResponse + */ +export type GetRackSlotsResponse = Message<"device_set.v1.GetRackSlotsResponse"> & { + /** + * Occupied slots ordered by row then column + * + * @generated from field: repeated device_set.v1.RackSlot slots = 1; + */ + slots: RackSlot[]; +}; + +/** + * Describes the message device_set.v1.GetRackSlotsResponse. + * Use `create(GetRackSlotsResponseSchema)` to create a new message. + */ +export const GetRackSlotsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 30); + +/** + * Aggregated telemetry stats for a single device set + * + * @generated from message device_set.v1.DeviceSetStats + */ +export type DeviceSetStats = Message<"device_set.v1.DeviceSetStats"> & { + /** + * Device set identifier + * + * @generated from field: int64 device_set_id = 1; + */ + deviceSetId: bigint; + + /** + * Total number of devices in the device set + * + * @generated from field: int32 device_count = 2; + */ + deviceCount: number; + + /** + * Number of devices with recent telemetry data + * + * @generated from field: int32 reporting_count = 3; + */ + reportingCount: number; + + /** + * Aggregated telemetry (totals/averages across reporting devices) + * + * @generated from field: double total_hashrate_ths = 4; + */ + totalHashrateThs: number; + + /** + * @generated from field: double avg_efficiency_jth = 5; + */ + avgEfficiencyJth: number; + + /** + * @generated from field: double total_power_kw = 6; + */ + totalPowerKw: number; + + /** + * @generated from field: double min_temperature_c = 7; + */ + minTemperatureC: number; + + /** + * @generated from field: double max_temperature_c = 8; + */ + maxTemperatureC: number; + + /** + * Fleet health state counts (mirrors dashboard FleetHealth buckets) + * + * ACTIVE + no auth issues + no actionable errors + * + * @generated from field: int32 hashing_count = 9; + */ + hashingCount: number; + + /** + * ERROR/NEEDS_MINING_POOL/AUTH_NEEDED or has open errors + * + * @generated from field: int32 broken_count = 10; + */ + brokenCount: number; + + /** + * OFFLINE or NULL status + * + * @generated from field: int32 offline_count = 11; + */ + offlineCount: number; + + /** + * MAINTENANCE or INACTIVE + * + * @generated from field: int32 sleeping_count = 12; + */ + sleepingCount: number; + + /** + * Per-metric reporting counts (devices that report each specific metric) + * + * @generated from field: int32 hashrate_reporting_count = 13; + */ + hashrateReportingCount: number; + + /** + * @generated from field: int32 efficiency_reporting_count = 14; + */ + efficiencyReportingCount: number; + + /** + * @generated from field: int32 power_reporting_count = 15; + */ + powerReportingCount: number; + + /** + * @generated from field: int32 temperature_reporting_count = 16; + */ + temperatureReportingCount: number; + + /** + * Component issue counts (number of devices with open errors by component type) + * + * @generated from field: int32 control_board_issue_count = 17; + */ + controlBoardIssueCount: number; + + /** + * @generated from field: int32 fan_issue_count = 18; + */ + fanIssueCount: number; + + /** + * @generated from field: int32 hash_board_issue_count = 19; + */ + hashBoardIssueCount: number; + + /** + * @generated from field: int32 psu_issue_count = 20; + */ + psuIssueCount: number; + + /** + * Per-slot device status for rack-type device sets (empty for groups). + * Contains one entry per row x column position, including empty slots. + * + * @generated from field: repeated device_set.v1.RackSlotStatus slot_statuses = 21; + */ + slotStatuses: RackSlotStatus[]; +}; + +/** + * Describes the message device_set.v1.DeviceSetStats. + * Use `create(DeviceSetStatsSchema)` to create a new message. + */ +export const DeviceSetStatsSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 31); + +/** + * Request to get aggregated stats for device sets + * + * @generated from message device_set.v1.GetDeviceSetStatsRequest + */ +export type GetDeviceSetStatsRequest = Message<"device_set.v1.GetDeviceSetStatsRequest"> & { + /** + * Device set IDs to get stats for + * + * @generated from field: repeated int64 device_set_ids = 1; + */ + deviceSetIds: bigint[]; +}; + +/** + * Describes the message device_set.v1.GetDeviceSetStatsRequest. + * Use `create(GetDeviceSetStatsRequestSchema)` to create a new message. + */ +export const GetDeviceSetStatsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 32); + +/** + * Response containing stats for each requested device set + * + * @generated from message device_set.v1.GetDeviceSetStatsResponse + */ +export type GetDeviceSetStatsResponse = Message<"device_set.v1.GetDeviceSetStatsResponse"> & { + /** + * Stats per device set (one entry per requested device set ID) + * + * @generated from field: repeated device_set.v1.DeviceSetStats stats = 1; + */ + stats: DeviceSetStats[]; +}; + +/** + * Describes the message device_set.v1.GetDeviceSetStatsResponse. + * Use `create(GetDeviceSetStatsResponseSchema)` to create a new message. + */ +export const GetDeviceSetStatsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 33); + +/** + * Status of a single slot in a rack grid + * + * @generated from message device_set.v1.RackSlotStatus + */ +export type RackSlotStatus = Message<"device_set.v1.RackSlotStatus"> & { + /** + * Row position (0-indexed) + * + * @generated from field: int32 row = 1; + */ + row: number; + + /** + * Column position (0-indexed) + * + * @generated from field: int32 column = 2; + */ + column: number; + + /** + * Device status for this slot + * + * @generated from field: device_set.v1.SlotDeviceStatus status = 3; + */ + status: SlotDeviceStatus; +}; + +/** + * Describes the message device_set.v1.RackSlotStatus. + * Use `create(RackSlotStatusSchema)` to create a new message. + */ +export const RackSlotStatusSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 34); + +/** + * Request to list all distinct rack zones for the organization + * + * @generated from message device_set.v1.ListRackZonesRequest + */ +export type ListRackZonesRequest = Message<"device_set.v1.ListRackZonesRequest"> & {}; + +/** + * Describes the message device_set.v1.ListRackZonesRequest. + * Use `create(ListRackZonesRequestSchema)` to create a new message. + */ +export const ListRackZonesRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 35); + +/** + * Response containing all distinct rack zones + * + * @generated from message device_set.v1.ListRackZonesResponse + */ +export type ListRackZonesResponse = Message<"device_set.v1.ListRackZonesResponse"> & { + /** + * Distinct zone strings across all racks, sorted alphabetically + * + * @generated from field: repeated string zones = 1; + */ + zones: string[]; +}; + +/** + * Describes the message device_set.v1.ListRackZonesResponse. + * Use `create(ListRackZonesResponseSchema)` to create a new message. + */ +export const ListRackZonesResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 36); + +/** + * Request to list all distinct rack types for the organization + * + * @generated from message device_set.v1.ListRackTypesRequest + */ +export type ListRackTypesRequest = Message<"device_set.v1.ListRackTypesRequest"> & {}; + +/** + * Describes the message device_set.v1.ListRackTypesRequest. + * Use `create(ListRackTypesRequestSchema)` to create a new message. + */ +export const ListRackTypesRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 37); + +/** + * A rack type defined by its row/column dimensions and how many racks use it + * + * @generated from message device_set.v1.RackType + */ +export type RackType = Message<"device_set.v1.RackType"> & { + /** + * Number of rows + * + * @generated from field: int32 rows = 1; + */ + rows: number; + + /** + * Number of columns + * + * @generated from field: int32 columns = 2; + */ + columns: number; + + /** + * Number of racks using this layout + * + * @generated from field: int32 rack_count = 3; + */ + rackCount: number; +}; + +/** + * Describes the message device_set.v1.RackType. + * Use `create(RackTypeSchema)` to create a new message. + */ +export const RackTypeSchema: GenMessage = /*@__PURE__*/ messageDesc(file_device_set_v1_device_set, 38); + +/** + * Response containing all distinct rack types + * + * @generated from message device_set.v1.ListRackTypesResponse + */ +export type ListRackTypesResponse = Message<"device_set.v1.ListRackTypesResponse"> & { + /** + * Distinct rack types ordered by most recently created rack using that layout + * + * @generated from field: repeated device_set.v1.RackType rack_types = 1; + */ + rackTypes: RackType[]; +}; + +/** + * Describes the message device_set.v1.ListRackTypesResponse. + * Use `create(ListRackTypesResponseSchema)` to create a new message. + */ +export const ListRackTypesResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 39); + +/** + * Request to atomically create or update a rack with membership and slot assignments. + * + * @generated from message device_set.v1.SaveRackRequest + */ +export type SaveRackRequest = Message<"device_set.v1.SaveRackRequest"> & { + /** + * ID of an existing rack to update. Omit to create a new rack. + * + * @generated from field: optional int64 device_set_id = 1; + */ + deviceSetId?: bigint; + + /** + * Label for the rack (required, 1-100 characters) + * + * @generated from field: string label = 2; + */ + label: string; + + /** + * Rack-specific metadata (required) + * + * @generated from field: device_set.v1.RackInfo rack_info = 3; + */ + rackInfo?: RackInfo; + + /** + * Devices that should be members of this rack. + * Replaces all existing members atomically. + * + * @generated from field: common.v1.DeviceSelector device_selector = 4; + */ + deviceSelector?: DeviceSelector; + + /** + * Slot assignments for devices within the rack. + * Only devices included in device_selector may be assigned slots. + * Devices not listed here will be members without a slot position. + * + * @generated from field: repeated device_set.v1.RackSlot slot_assignments = 5; + */ + slotAssignments: RackSlot[]; +}; + +/** + * Describes the message device_set.v1.SaveRackRequest. + * Use `create(SaveRackRequestSchema)` to create a new message. + */ +export const SaveRackRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 40); + +/** + * Response after saving a rack. + * + * @generated from message device_set.v1.SaveRackResponse + */ +export type SaveRackResponse = Message<"device_set.v1.SaveRackResponse"> & { + /** + * The created or updated rack device set + * + * @generated from field: device_set.v1.DeviceSet device_set = 1; + */ + deviceSet?: DeviceSet; + + /** + * Number of slot positions assigned + * + * @generated from field: int32 assigned_count = 2; + */ + assignedCount: number; +}; + +/** + * Describes the message device_set.v1.SaveRackResponse. + * Use `create(SaveRackResponseSchema)` to create a new message. + */ +export const SaveRackResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 41); + +/** + * Type of device set + * + * @generated from enum device_set.v1.DeviceSetType + */ +export enum DeviceSetType { + /** + * Unspecified type - returns all types when filtering + * + * @generated from enum value: DEVICE_SET_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Group: many-to-many relationship (device can belong to multiple groups) + * + * @generated from enum value: DEVICE_SET_TYPE_GROUP = 1; + */ + GROUP = 1, + + /** + * Rack: one-to-one relationship (device can only be in one rack) + * + * @generated from enum value: DEVICE_SET_TYPE_RACK = 2; + */ + RACK = 2, +} + +/** + * Describes the enum device_set.v1.DeviceSetType. + */ +export const DeviceSetTypeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_device_set_v1_device_set, 0); + +/** + * Order index defining where row/column numbering starts in a rack + * + * @generated from enum device_set.v1.RackOrderIndex + */ +export enum RackOrderIndex { + /** + * @generated from enum value: RACK_ORDER_INDEX_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: RACK_ORDER_INDEX_BOTTOM_LEFT = 1; + */ + BOTTOM_LEFT = 1, + + /** + * @generated from enum value: RACK_ORDER_INDEX_TOP_LEFT = 2; + */ + TOP_LEFT = 2, + + /** + * @generated from enum value: RACK_ORDER_INDEX_BOTTOM_RIGHT = 3; + */ + BOTTOM_RIGHT = 3, + + /** + * @generated from enum value: RACK_ORDER_INDEX_TOP_RIGHT = 4; + */ + TOP_RIGHT = 4, +} + +/** + * Describes the enum device_set.v1.RackOrderIndex. + */ +export const RackOrderIndexSchema: GenEnum = /*@__PURE__*/ enumDesc(file_device_set_v1_device_set, 1); + +/** + * Cooling type for a rack + * + * @generated from enum device_set.v1.RackCoolingType + */ +export enum RackCoolingType { + /** + * @generated from enum value: RACK_COOLING_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: RACK_COOLING_TYPE_AIR = 1; + */ + AIR = 1, + + /** + * @generated from enum value: RACK_COOLING_TYPE_IMMERSION = 2; + */ + IMMERSION = 2, +} + +/** + * Describes the enum device_set.v1.RackCoolingType. + */ +export const RackCoolingTypeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_device_set_v1_device_set, 2); + +/** + * Status of a device in a specific rack slot position + * + * @generated from enum device_set.v1.SlotDeviceStatus + */ +export enum SlotDeviceStatus { + /** + * @generated from enum value: SLOT_DEVICE_STATUS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: SLOT_DEVICE_STATUS_EMPTY = 1; + */ + EMPTY = 1, + + /** + * @generated from enum value: SLOT_DEVICE_STATUS_HEALTHY = 2; + */ + HEALTHY = 2, + + /** + * @generated from enum value: SLOT_DEVICE_STATUS_NEEDS_ATTENTION = 3; + */ + NEEDS_ATTENTION = 3, + + /** + * @generated from enum value: SLOT_DEVICE_STATUS_OFFLINE = 4; + */ + OFFLINE = 4, + + /** + * @generated from enum value: SLOT_DEVICE_STATUS_SLEEPING = 5; + */ + SLEEPING = 5, +} + +/** + * Describes the enum device_set.v1.SlotDeviceStatus. + */ +export const SlotDeviceStatusSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_device_set_v1_device_set, 3); + +/** + * Service for managing device sets (groups and racks) + * Device sets allow grouping devices for filtering and bulk operations + * + * @generated from service device_set.v1.DeviceSetService + */ +export const DeviceSetService: GenService<{ + /** + * Creates a new device set + * + * @generated from rpc device_set.v1.DeviceSetService.CreateDeviceSet + */ + createDeviceSet: { + methodKind: "unary"; + input: typeof CreateDeviceSetRequestSchema; + output: typeof CreateDeviceSetResponseSchema; + }; + /** + * Gets a device set by ID + * + * @generated from rpc device_set.v1.DeviceSetService.GetDeviceSet + */ + getDeviceSet: { + methodKind: "unary"; + input: typeof GetDeviceSetRequestSchema; + output: typeof GetDeviceSetResponseSchema; + }; + /** + * Updates a device set's label or description + * + * @generated from rpc device_set.v1.DeviceSetService.UpdateDeviceSet + */ + updateDeviceSet: { + methodKind: "unary"; + input: typeof UpdateDeviceSetRequestSchema; + output: typeof UpdateDeviceSetResponseSchema; + }; + /** + * Deletes a device set (soft delete) + * + * @generated from rpc device_set.v1.DeviceSetService.DeleteDeviceSet + */ + deleteDeviceSet: { + methodKind: "unary"; + input: typeof DeleteDeviceSetRequestSchema; + output: typeof DeleteDeviceSetResponseSchema; + }; + /** + * Lists all device sets for the organization + * + * @generated from rpc device_set.v1.DeviceSetService.ListDeviceSets + */ + listDeviceSets: { + methodKind: "unary"; + input: typeof ListDeviceSetsRequestSchema; + output: typeof ListDeviceSetsResponseSchema; + }; + /** + * Adds devices to a device set + * + * @generated from rpc device_set.v1.DeviceSetService.AddDevicesToDeviceSet + */ + addDevicesToDeviceSet: { + methodKind: "unary"; + input: typeof AddDevicesToDeviceSetRequestSchema; + output: typeof AddDevicesToDeviceSetResponseSchema; + }; + /** + * Removes devices from a device set + * + * @generated from rpc device_set.v1.DeviceSetService.RemoveDevicesFromDeviceSet + */ + removeDevicesFromDeviceSet: { + methodKind: "unary"; + input: typeof RemoveDevicesFromDeviceSetRequestSchema; + output: typeof RemoveDevicesFromDeviceSetResponseSchema; + }; + /** + * Lists members of a device set + * + * @generated from rpc device_set.v1.DeviceSetService.ListDeviceSetMembers + */ + listDeviceSetMembers: { + methodKind: "unary"; + input: typeof ListDeviceSetMembersRequestSchema; + output: typeof ListDeviceSetMembersResponseSchema; + }; + /** + * Gets device sets that a device belongs to + * + * @generated from rpc device_set.v1.DeviceSetService.GetDeviceDeviceSets + */ + getDeviceDeviceSets: { + methodKind: "unary"; + input: typeof GetDeviceDeviceSetsRequestSchema; + output: typeof GetDeviceDeviceSetsResponseSchema; + }; + /** + * Sets a device's slot position within a rack + * + * @generated from rpc device_set.v1.DeviceSetService.SetRackSlotPosition + */ + setRackSlotPosition: { + methodKind: "unary"; + input: typeof SetRackSlotPositionRequestSchema; + output: typeof SetRackSlotPositionResponseSchema; + }; + /** + * Clears a device's slot position within a rack + * + * @generated from rpc device_set.v1.DeviceSetService.ClearRackSlotPosition + */ + clearRackSlotPosition: { + methodKind: "unary"; + input: typeof ClearRackSlotPositionRequestSchema; + output: typeof ClearRackSlotPositionResponseSchema; + }; + /** + * Lists all occupied slot positions in a rack + * + * @generated from rpc device_set.v1.DeviceSetService.GetRackSlots + */ + getRackSlots: { + methodKind: "unary"; + input: typeof GetRackSlotsRequestSchema; + output: typeof GetRackSlotsResponseSchema; + }; + /** + * Returns aggregated telemetry stats for a list of device sets + * + * @generated from rpc device_set.v1.DeviceSetService.GetDeviceSetStats + */ + getDeviceSetStats: { + methodKind: "unary"; + input: typeof GetDeviceSetStatsRequestSchema; + output: typeof GetDeviceSetStatsResponseSchema; + }; + /** + * Returns all distinct rack zones for the organization + * + * @generated from rpc device_set.v1.DeviceSetService.ListRackZones + */ + listRackZones: { + methodKind: "unary"; + input: typeof ListRackZonesRequestSchema; + output: typeof ListRackZonesResponseSchema; + }; + /** + * Returns all distinct rack types (row/column combinations) for the organization + * + * @generated from rpc device_set.v1.DeviceSetService.ListRackTypes + */ + listRackTypes: { + methodKind: "unary"; + input: typeof ListRackTypesRequestSchema; + output: typeof ListRackTypesResponseSchema; + }; + /** + * Atomically creates or updates a rack with its membership and slot assignments. + * All operations (metadata, membership, slot positions) are applied in a single transaction. + * + * @generated from rpc device_set.v1.DeviceSetService.SaveRack + */ + saveRack: { + methodKind: "unary"; + input: typeof SaveRackRequestSchema; + output: typeof SaveRackResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_device_set_v1_device_set, 0); diff --git a/client/src/protoFleet/api/generated/errors/v1/errors_pb.ts b/client/src/protoFleet/api/generated/errors/v1/errors_pb.ts new file mode 100644 index 000000000..4a3652ab9 --- /dev/null +++ b/client/src/protoFleet/api/generated/errors/v1/errors_pb.ts @@ -0,0 +1,1244 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file errors/v1/errors.proto (package errors.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import { file_buf_validate_validate } from "../../buf/validate/validate_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file errors/v1/errors.proto. + */ +export const file_errors_v1_errors: GenFile = + /*@__PURE__*/ + fileDesc( + "ChZlcnJvcnMvdjEvZXJyb3JzLnByb3RvEgllcnJvcnMudjEi2wQKDEVycm9yTWVzc2FnZRIQCghlcnJvcl9pZBgBIAEoCRIuCg9jYW5vbmljYWxfZXJyb3IYAiABKA4yFS5lcnJvcnMudjEuTWluZXJFcnJvchIPCgdzdW1tYXJ5GAMgASgJEhUKDWNhdXNlX3N1bW1hcnkYBCABKAkSGgoScmVjb21tZW5kZWRfYWN0aW9uGAUgASgJEiUKCHNldmVyaXR5GAYgASgOMhMuZXJyb3JzLnYxLlNldmVyaXR5EjEKDWZpcnN0X3NlZW5fYXQYByABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjAKDGxhc3Rfc2Vlbl9hdBgIIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLQoJY2xvc2VkX2F0GAkgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBJIChF2ZW5kb3JfYXR0cmlidXRlcxgKIAMoCzItLmVycm9ycy52MS5FcnJvck1lc3NhZ2UuVmVuZG9yQXR0cmlidXRlc0VudHJ5EhkKEWRldmljZV9pZGVudGlmaWVyGAsgASgJEhkKDGNvbXBvbmVudF9pZBgMIAEoCUgAiAEBEg4KBmltcGFjdBgNIAEoCRIwCg5jb21wb25lbnRfdHlwZRgOIAEoDjIYLmVycm9ycy52MS5Db21wb25lbnRUeXBlGjcKFVZlbmRvckF0dHJpYnV0ZXNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBQg8KDV9jb21wb25lbnRfaWQiPAoHU3VtbWFyeRINCgV0aXRsZRgBIAEoCRIPCgdkZXRhaWxzGAIgASgJEhEKCWNvbmRlbnNlZBgDIAEoCSKxAgoLRGV2aWNlRXJyb3ISGQoRZGV2aWNlX2lkZW50aWZpZXIYASABKAkSEwoLZGV2aWNlX3R5cGUYAiABKAkSIQoGc3RhdHVzGAMgASgOMhEuZXJyb3JzLnYxLlN0YXR1cxIjCgdzdW1tYXJ5GAQgASgLMhIuZXJyb3JzLnYxLlN1bW1hcnkSJwoGZXJyb3JzGAUgAygLMhcuZXJyb3JzLnYxLkVycm9yTWVzc2FnZRJIChJjb3VudHNfYnlfc2V2ZXJpdHkYBiADKAsyLC5lcnJvcnMudjEuRGV2aWNlRXJyb3IuQ291bnRzQnlTZXZlcml0eUVudHJ5GjcKFUNvdW50c0J5U2V2ZXJpdHlFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAU6AjgBIuoCCg5Db21wb25lbnRFcnJvchIUCgxjb21wb25lbnRfaWQYASABKAkSMAoOY29tcG9uZW50X3R5cGUYAiABKA4yGC5lcnJvcnMudjEuQ29tcG9uZW50VHlwZRIZChFkZXZpY2VfaWRlbnRpZmllchgDIAEoCRIhCgZzdGF0dXMYBCABKA4yES5lcnJvcnMudjEuU3RhdHVzEiMKB3N1bW1hcnkYBSABKAsyEi5lcnJvcnMudjEuU3VtbWFyeRInCgZlcnJvcnMYBiADKAsyFy5lcnJvcnMudjEuRXJyb3JNZXNzYWdlEksKEmNvdW50c19ieV9zZXZlcml0eRgHIAMoCzIvLmVycm9ycy52MS5Db21wb25lbnRFcnJvci5Db3VudHNCeVNldmVyaXR5RW50cnkaNwoVQ291bnRzQnlTZXZlcml0eUVudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoBToCOAEi5AEKDFNpbXBsZUZpbHRlchIaChJkZXZpY2VfaWRlbnRpZmllcnMYASADKAkSFAoMZGV2aWNlX3R5cGVzGAIgAygJEhUKDWNvbXBvbmVudF9pZHMYAyADKAkSMQoPY29tcG9uZW50X3R5cGVzGAQgAygOMhguZXJyb3JzLnYxLkNvbXBvbmVudFR5cGUSLwoQY2Fub25pY2FsX2Vycm9ycxgFIAMoDjIVLmVycm9ycy52MS5NaW5lckVycm9yEicKCnNldmVyaXRpZXMYBiADKA4yEy5lcnJvcnMudjEuU2V2ZXJpdHki0wEKBkZpbHRlchInCgZzaW1wbGUYASABKAsyFy5lcnJvcnMudjEuU2ltcGxlRmlsdGVyEiwKDHNpbXBsZV9sb2dpYxgCIAEoDjIWLmVycm9ycy52MS5HbG9iYWxMb2dpYxItCgl0aW1lX2Zyb20YAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEisKB3RpbWVfdG8YBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhYKDmluY2x1ZGVfY2xvc2VkGAUgASgIIqIBCgxRdWVyeVJlcXVlc3QSKgoLcmVzdWx0X3ZpZXcYASABKA4yFS5lcnJvcnMudjEuUmVzdWx0VmlldxIhCgZmaWx0ZXIYAiABKAsyES5lcnJvcnMudjEuRmlsdGVyEh0KCXBhZ2Vfc2l6ZRgDIAEoBUIKukgHGgUY6AcoARISCgpwYWdlX3Rva2VuGAQgASgJEhAKCG9yZGVyX2J5GAUgASgJIsoBCg1RdWVyeVJlc3BvbnNlEiMKBmVycm9ycxgBIAEoCzIRLmVycm9ycy52MS5FcnJvcnNIABIwCgpjb21wb25lbnRzGAIgASgLMhouZXJyb3JzLnYxLkNvbXBvbmVudEVycm9yc0gAEioKB2RldmljZXMYAyABKAsyFy5lcnJvcnMudjEuRGV2aWNlRXJyb3JzSAASFwoPbmV4dF9wYWdlX3Rva2VuGAogASgJEhMKC3RvdGFsX2NvdW50GAsgASgDQggKBnJlc3VsdCIwCgZFcnJvcnMSJgoFaXRlbXMYASADKAsyFy5lcnJvcnMudjEuRXJyb3JNZXNzYWdlIjsKD0NvbXBvbmVudEVycm9ycxIoCgVpdGVtcxgBIAMoCzIZLmVycm9ycy52MS5Db21wb25lbnRFcnJvciI1CgxEZXZpY2VFcnJvcnMSJQoFaXRlbXMYASADKAsyFi5lcnJvcnMudjEuRGV2aWNlRXJyb3IiLAoPR2V0RXJyb3JSZXF1ZXN0EhkKCGVycm9yX2lkGAEgASgJQge6SARyAhABIjoKEEdldEVycm9yUmVzcG9uc2USJgoFZXJyb3IYASABKAsyFy5lcnJvcnMudjEuRXJyb3JNZXNzYWdlIhgKFkxpc3RNaW5lckVycm9yc1JlcXVlc3QiQwoXTGlzdE1pbmVyRXJyb3JzUmVzcG9uc2USKAoFaXRlbXMYASADKAsyGS5lcnJvcnMudjEuTWluZXJFcnJvckluZm8iuwEKDk1pbmVyRXJyb3JJbmZvEiMKBGNvZGUYASABKA4yFS5lcnJvcnMudjEuTWluZXJFcnJvchIMCgRuYW1lGAIgASgJEhcKD2RlZmF1bHRfc3VtbWFyeRgDIAEoCRItChBkZWZhdWx0X3NldmVyaXR5GAQgASgOMhMuZXJyb3JzLnYxLlNldmVyaXR5EhYKDmRlZmF1bHRfYWN0aW9uGAUgASgJEhYKDmRlZmF1bHRfaW1wYWN0GAYgASgJIjEKDFdhdGNoUmVxdWVzdBIhCgZmaWx0ZXIYASABKAsyES5lcnJvcnMudjEuRmlsdGVyIpsCCg1XYXRjaFJlc3BvbnNlEiMKBmVycm9ycxgBIAEoCzIRLmVycm9ycy52MS5FcnJvcnNIABIwCgpjb21wb25lbnRzGAIgASgLMhouZXJyb3JzLnYxLkNvbXBvbmVudEVycm9yc0gAEioKB2RldmljZXMYAyABKAsyFy5lcnJvcnMudjEuRGV2aWNlRXJyb3JzSAASKwoEa2luZBgEIAEoDjIdLmVycm9ycy52MS5XYXRjaFJlc3BvbnNlLktpbmQiUAoES2luZBIUChBLSU5EX1VOU1BFQ0lGSUVEEAASDwoLS0lORF9PUEVORUQQARIQCgxLSU5EX1VQREFURUQQAhIPCgtLSU5EX0NMT1NFRBADQggKBnJlc3VsdCrjDwoKTWluZXJFcnJvchIbChdNSU5FUl9FUlJPUl9VTlNQRUNJRklFRBAAEiAKG01JTkVSX0VSUk9SX1BTVV9OT1RfUFJFU0VOVBDoBxIjCh5NSU5FUl9FUlJPUl9QU1VfTU9ERUxfTUlTTUFUQ0gQ6QcSJwoiTUlORVJfRVJST1JfUFNVX0NPTU1VTklDQVRJT05fTE9TVBDqBxIiCh1NSU5FUl9FUlJPUl9QU1VfRkFVTFRfR0VORVJJQxDrBxImCiFNSU5FUl9FUlJPUl9QU1VfSU5QVVRfVk9MVEFHRV9MT1cQ7AcSJwoiTUlORVJfRVJST1JfUFNVX0lOUFVUX1ZPTFRBR0VfSElHSBDtBxIpCiRNSU5FUl9FUlJPUl9QU1VfT1VUUFVUX1ZPTFRBR0VfRkFVTFQQ7gcSJwoiTUlORVJfRVJST1JfUFNVX09VVFBVVF9PVkVSQ1VSUkVOVBDvBxIfChpNSU5FUl9FUlJPUl9QU1VfRkFOX0ZBSUxFRBDwBxIlCiBNSU5FUl9FUlJPUl9QU1VfT1ZFUl9URU1QRVJBVFVSRRDxBxIqCiVNSU5FUl9FUlJPUl9QU1VfSU5QVVRfUEhBU0VfSU1CQUxBTkNFEPIHEiYKIU1JTkVSX0VSUk9SX1BTVV9VTkRFUl9URU1QRVJBVFVSRRDzBxIbChZNSU5FUl9FUlJPUl9GQU5fRkFJTEVEENAPEiUKIE1JTkVSX0VSUk9SX0ZBTl9UQUNIX1NJR05BTF9MT1NUENEPEiQKH01JTkVSX0VSUk9SX0ZBTl9TUEVFRF9ERVZJQVRJT04Q0g8SJwoiTUlORVJfRVJST1JfSU5MRVRfT1ZFUl9URU1QRVJBVFVSRRDaDxIoCiNNSU5FUl9FUlJPUl9ERVZJQ0VfT1ZFUl9URU1QRVJBVFVSRRDbDxIpCiRNSU5FUl9FUlJPUl9ERVZJQ0VfVU5ERVJfVEVNUEVSQVRVUkUQ3A8SJgohTUlORVJfRVJST1JfSEFTSEJPQVJEX05PVF9QUkVTRU5UELgXEisKJk1JTkVSX0VSUk9SX0hBU0hCT0FSRF9PVkVSX1RFTVBFUkFUVVJFELkXEigKI01JTkVSX0VSUk9SX0hBU0hCT0FSRF9NSVNTSU5HX0NISVBTELoXEi4KKU1JTkVSX0VSUk9SX0FTSUNfQ0hBSU5fQ09NTVVOSUNBVElPTl9MT1NUELsXEigKI01JTkVSX0VSUk9SX0FTSUNfQ0xPQ0tfUExMX1VOTE9DS0VEELwXEikKJE1JTkVSX0VSUk9SX0FTSUNfQ1JDX0VSUk9SX0VYQ0VTU0lWRRC9FxIwCitNSU5FUl9FUlJPUl9IQVNIQk9BUkRfQVNJQ19PVkVSX1RFTVBFUkFUVVJFEL4XEjEKLE1JTkVSX0VSUk9SX0hBU0hCT0FSRF9BU0lDX1VOREVSX1RFTVBFUkFUVVJFEL8XEioKJU1JTkVSX0VSUk9SX0JPQVJEX1BPV0VSX1BHT09EX01JU1NJTkcQrBsSLQooTUlORVJfRVJST1JfQk9BUkRfUE9XRVJfT1ZFUkNVUlJFTlRfVFJJUBCtGxIrCiZNSU5FUl9FUlJPUl9CT0FSRF9QT1dFUl9SQUlMX1VOREVSVk9MVBCuGxIqCiVNSU5FUl9FUlJPUl9CT0FSRF9QT1dFUl9SQUlMX09WRVJWT0xUEK8bEisKJk1JTkVSX0VSUk9SX0JPQVJEX1BPV0VSX1NIT1JUX0RFVEVDVEVEELAbEioKJU1JTkVSX0VSUk9SX1RFTVBfU0VOU09SX09QRU5fT1JfU0hPUlQQoB8SIgodTUlORVJfRVJST1JfVEVNUF9TRU5TT1JfRkFVTFQQoR8SJQogTUlORVJfRVJST1JfVk9MVEFHRV9TRU5TT1JfRkFVTFQQoh8SJQogTUlORVJfRVJST1JfQ1VSUkVOVF9TRU5TT1JfRkFVTFQQox8SJAofTUlORVJfRVJST1JfRUVQUk9NX0NSQ19NSVNNQVRDSBCIJxIkCh9NSU5FUl9FUlJPUl9FRVBST01fUkVBRF9GQUlMVVJFEIknEicKIk1JTkVSX0VSUk9SX0ZJUk1XQVJFX0lNQUdFX0lOVkFMSUQQiicSKAojTUlORVJfRVJST1JfRklSTVdBUkVfQ09ORklHX0lOVkFMSUQQiycSMQosTUlORVJfRVJST1JfQ09OVFJPTF9CT0FSRF9DT01NVU5JQ0FUSU9OX0xPU1QQ8C4SJgohTUlORVJfRVJST1JfQ09OVFJPTF9CT0FSRF9GQUlMVVJFEPEuEioKJU1JTkVSX0VSUk9SX0RFVklDRV9JTlRFUk5BTF9CVVNfRkFVTFQQ8i4SKgolTUlORVJfRVJST1JfREVWSUNFX0NPTU1VTklDQVRJT05fTE9TVBDzLhIiCh1NSU5FUl9FUlJPUl9JT19NT0RVTEVfRkFJTFVSRRD6LhImCiFNSU5FUl9FUlJPUl9IQVNIUkFURV9CRUxPV19UQVJHRVQQwD4SKAojTUlORVJfRVJST1JfSEFTSEJPQVJEX1dBUk5fQ1JDX0hJR0gQwT4SIwoeTUlORVJfRVJST1JfVEhFUk1BTF9NQVJHSU5fTE9XEMI+EiYKIU1JTkVSX0VSUk9SX1ZFTkRPUl9FUlJPUl9VTk1BUFBFRBCoRip2CghTZXZlcml0eRIYChRTRVZFUklUWV9VTlNQRUNJRklFRBAAEhUKEVNFVkVSSVRZX0NSSVRJQ0FMEAESEgoOU0VWRVJJVFlfTUFKT1IQAhISCg5TRVZFUklUWV9NSU5PUhADEhEKDVNFVkVSSVRZX0lORk8QBCpVCgZTdGF0dXMSFgoSU1RBVFVTX1VOU1BFQ0lGSUVEEAASDQoJU1RBVFVTX09LEAESEgoOU1RBVFVTX1dBUk5JTkcQAhIQCgxTVEFUVVNfRVJST1IQAyrZAQoNQ29tcG9uZW50VHlwZRIeChpDT01QT05FTlRfVFlQRV9VTlNQRUNJRklFRBAAEhYKEkNPTVBPTkVOVF9UWVBFX1BTVRABEh0KGUNPTVBPTkVOVF9UWVBFX0hBU0hfQk9BUkQQAhIWChJDT01QT05FTlRfVFlQRV9GQU4QAxIgChxDT01QT05FTlRfVFlQRV9DT05UUk9MX0JPQVJEEAQSGQoVQ09NUE9ORU5UX1RZUEVfRUVQUk9NEAUSHAoYQ09NUE9ORU5UX1RZUEVfSU9fTU9EVUxFEAYqcwoKUmVzdWx0VmlldxIbChdSRVNVTFRfVklFV19VTlNQRUNJRklFRBAAEhUKEVJFU1VMVF9WSUVXX0VSUk9SEAESGQoVUkVTVUxUX1ZJRVdfQ09NUE9ORU5UEAISFgoSUkVTVUxUX1ZJRVdfREVWSUNFEAMqVgoLR2xvYmFsTG9naWMSHAoYR0xPQkFMX0xPR0lDX1VOU1BFQ0lGSUVEEAASFAoQR0xPQkFMX0xPR0lDX0FORBABEhMKD0dMT0JBTF9MT0dJQ19PUhACMqwCChFFcnJvclF1ZXJ5U2VydmljZRI6CgVRdWVyeRIXLmVycm9ycy52MS5RdWVyeVJlcXVlc3QaGC5lcnJvcnMudjEuUXVlcnlSZXNwb25zZRJDCghHZXRFcnJvchIaLmVycm9ycy52MS5HZXRFcnJvclJlcXVlc3QaGy5lcnJvcnMudjEuR2V0RXJyb3JSZXNwb25zZRJYCg9MaXN0TWluZXJFcnJvcnMSIS5lcnJvcnMudjEuTGlzdE1pbmVyRXJyb3JzUmVxdWVzdBoiLmVycm9ycy52MS5MaXN0TWluZXJFcnJvcnNSZXNwb25zZRI8CgVXYXRjaBIXLmVycm9ycy52MS5XYXRjaFJlcXVlc3QaGC5lcnJvcnMudjEuV2F0Y2hSZXNwb25zZTABQqgBCg1jb20uZXJyb3JzLnYxQgtFcnJvcnNQcm90b1ABWkVnaXRodWIuY29tL2Jsb2NrL3Byb3RvLWZsZWV0L3NlcnZlci9nZW5lcmF0ZWQvZ3JwYy9lcnJvcnMvdjE7ZXJyb3JzdjGiAgNFWFiqAglFcnJvcnMuVjHKAglFcnJvcnNcVjHiAhVFcnJvcnNcVjFcR1BCTWV0YWRhdGHqAgpFcnJvcnM6OlYxYgZwcm90bzM", + [file_google_protobuf_timestamp, file_buf_validate_validate], + ); + +/** + * A single error instance + * + * @generated from message errors.v1.ErrorMessage + */ +export type ErrorMessage = Message<"errors.v1.ErrorMessage"> & { + /** + * ULID (globally unique, time-sortable) + * + * @generated from field: string error_id = 1; + */ + errorId: string; + + /** + * REQUIRED + * + * @generated from field: errors.v1.MinerError canonical_error = 2; + */ + canonicalError: MinerError; + + /** + * Human-readable short description (typically vendor-provided) + * + * @generated from field: string summary = 3; + */ + summary: string; + + /** + * Human-readable short reason + * + * @generated from field: string cause_summary = 4; + */ + causeSummary: string; + + /** + * Next best action + * + * @generated from field: string recommended_action = 5; + */ + recommendedAction: string; + + /** + * Technical severity classification + * + * @generated from field: errors.v1.Severity severity = 6; + */ + severity: Severity; + + /** + * @generated from field: google.protobuf.Timestamp first_seen_at = 7; + */ + firstSeenAt?: Timestamp; + + /** + * @generated from field: google.protobuf.Timestamp last_seen_at = 8; + */ + lastSeenAt?: Timestamp; + + /** + * Optional: when error was resolved + * + * @generated from field: google.protobuf.Timestamp closed_at = 9; + */ + closedAt?: Timestamp; + + /** + * e.g., firmware, code, serials + * + * @generated from field: map vendor_attributes = 10; + */ + vendorAttributes: { [key: string]: string }; + + /** + * device_identifier of the Device this error belongs to + * + * @generated from field: string device_identifier = 11; + */ + deviceIdentifier: string; + + /** + * Optional component identifier + * + * @generated from field: optional string component_id = 12; + */ + componentId?: string; + + /** + * Human-readable business impact + * + * @generated from field: string impact = 13; + */ + impact: string; + + /** + * Type of hardware component (hashboard, fan, PSU, etc.) + * + * @generated from field: errors.v1.ComponentType component_type = 14; + */ + componentType: ComponentType; +}; + +/** + * Describes the message errors.v1.ErrorMessage. + * Use `create(ErrorMessageSchema)` to create a new message. + */ +export const ErrorMessageSchema: GenMessage = /*@__PURE__*/ messageDesc(file_errors_v1_errors, 0); + +/** + * Rich summary structure for aggregated views + * + * @generated from message errors.v1.Summary + */ +export type Summary = Message<"errors.v1.Summary"> & { + /** + * One-liner: "3 critical PSU errors" + * + * @generated from field: string title = 1; + */ + title: string; + + /** + * Full multi-line description with context + * + * @generated from field: string details = 2; + */ + details: string; + + /** + * Ultra-short for UI chips: "3 PSU" + * + * @generated from field: string condensed = 3; + */ + condensed: string; +}; + +/** + * Describes the message errors.v1.Summary. + * Use `create(SummarySchema)` to create a new message. + */ +export const SummarySchema: GenMessage = /*@__PURE__*/ messageDesc(file_errors_v1_errors, 1); + +/** + * Errors grouped by device + * + * @generated from message errors.v1.DeviceError + */ +export type DeviceError = Message<"errors.v1.DeviceError"> & { + /** + * @generated from field: string device_identifier = 1; + */ + deviceIdentifier: string; + + /** + * Model name + * + * @generated from field: string device_type = 2; + */ + deviceType: string; + + /** + * @generated from field: errors.v1.Status status = 3; + */ + status: Status; + + /** + * @generated from field: errors.v1.Summary summary = 4; + */ + summary?: Summary; + + /** + * @generated from field: repeated errors.v1.ErrorMessage errors = 5; + */ + errors: ErrorMessage[]; + + /** + * Error counts per severity level + * + * @generated from field: map counts_by_severity = 6; + */ + countsBySeverity: { [key: string]: number }; +}; + +/** + * Describes the message errors.v1.DeviceError. + * Use `create(DeviceErrorSchema)` to create a new message. + */ +export const DeviceErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_errors_v1_errors, 2); + +/** + * Errors grouped by component + * + * @generated from message errors.v1.ComponentError + */ +export type ComponentError = Message<"errors.v1.ComponentError"> & { + /** + * @generated from field: string component_id = 1; + */ + componentId: string; + + /** + * @generated from field: errors.v1.ComponentType component_type = 2; + */ + componentType: ComponentType; + + /** + * @generated from field: string device_identifier = 3; + */ + deviceIdentifier: string; + + /** + * @generated from field: errors.v1.Status status = 4; + */ + status: Status; + + /** + * @generated from field: errors.v1.Summary summary = 5; + */ + summary?: Summary; + + /** + * @generated from field: repeated errors.v1.ErrorMessage errors = 6; + */ + errors: ErrorMessage[]; + + /** + * Error counts per severity level + * + * @generated from field: map counts_by_severity = 7; + */ + countsBySeverity: { [key: string]: number }; +}; + +/** + * Describes the message errors.v1.ComponentError. + * Use `create(ComponentErrorSchema)` to create a new message. + */ +export const ComponentErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_errors_v1_errors, 3); + +/** + * Simple filter with field lists + * + * @generated from message errors.v1.SimpleFilter + */ +export type SimpleFilter = Message<"errors.v1.SimpleFilter"> & { + /** + * Device identifiers (e.g., "proto-12345") + * + * @generated from field: repeated string device_identifiers = 1; + */ + deviceIdentifiers: string[]; + + /** + * Model names (e.g., "R2", "S19") + * + * @generated from field: repeated string device_types = 2; + */ + deviceTypes: string[]; + + /** + * @generated from field: repeated string component_ids = 3; + */ + componentIds: string[]; + + /** + * @generated from field: repeated errors.v1.ComponentType component_types = 4; + */ + componentTypes: ComponentType[]; + + /** + * @generated from field: repeated errors.v1.MinerError canonical_errors = 5; + */ + canonicalErrors: MinerError[]; + + /** + * Filter by technical severity + * + * @generated from field: repeated errors.v1.Severity severities = 6; + */ + severities: Severity[]; +}; + +/** + * Describes the message errors.v1.SimpleFilter. + * Use `create(SimpleFilterSchema)` to create a new message. + */ +export const SimpleFilterSchema: GenMessage = /*@__PURE__*/ messageDesc(file_errors_v1_errors, 4); + +/** + * Complete filter with time range and logic options + * + * @generated from message errors.v1.Filter + */ +export type Filter = Message<"errors.v1.Filter"> & { + /** + * @generated from field: errors.v1.SimpleFilter simple = 1; + */ + simple?: SimpleFilter; + + /** + * @generated from field: errors.v1.GlobalLogic simple_logic = 2; + */ + simpleLogic: GlobalLogic; + + /** + * Optional + * + * @generated from field: google.protobuf.Timestamp time_from = 3; + */ + timeFrom?: Timestamp; + + /** + * Optional + * + * @generated from field: google.protobuf.Timestamp time_to = 4; + */ + timeTo?: Timestamp; + + /** + * Include closed/expired errors + * + * @generated from field: bool include_closed = 5; + */ + includeClosed: boolean; +}; + +/** + * Describes the message errors.v1.Filter. + * Use `create(FilterSchema)` to create a new message. + */ +export const FilterSchema: GenMessage = /*@__PURE__*/ messageDesc(file_errors_v1_errors, 5); + +/** + * Query request with pagination + * + * @generated from message errors.v1.QueryRequest + */ +export type QueryRequest = Message<"errors.v1.QueryRequest"> & { + /** + * @generated from field: errors.v1.ResultView result_view = 1; + */ + resultView: ResultView; + + /** + * @generated from field: errors.v1.Filter filter = 2; + */ + filter?: Filter; + + /** + * @generated from field: int32 page_size = 3; + */ + pageSize: number; + + /** + * @generated from field: string page_token = 4; + */ + pageToken: string; + + /** + * Reserved for future use. Currently sorting is fixed: severity DESC, last_seen_at DESC, error_id DESC. + * This field is ignored by the server. + * + * @generated from field: string order_by = 5; + */ + orderBy: string; +}; + +/** + * Describes the message errors.v1.QueryRequest. + * Use `create(QueryRequestSchema)` to create a new message. + */ +export const QueryRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_errors_v1_errors, 6); + +/** + * Query response with polymorphic result + * + * @generated from message errors.v1.QueryResponse + */ +export type QueryResponse = Message<"errors.v1.QueryResponse"> & { + /** + * @generated from oneof errors.v1.QueryResponse.result + */ + result: + | { + /** + * @generated from field: errors.v1.Errors errors = 1; + */ + value: Errors; + case: "errors"; + } + | { + /** + * @generated from field: errors.v1.ComponentErrors components = 2; + */ + value: ComponentErrors; + case: "components"; + } + | { + /** + * @generated from field: errors.v1.DeviceErrors devices = 3; + */ + value: DeviceErrors; + case: "devices"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from field: string next_page_token = 10; + */ + nextPageToken: string; + + /** + * Total matching items + * + * @generated from field: int64 total_count = 11; + */ + totalCount: bigint; +}; + +/** + * Describes the message errors.v1.QueryResponse. + * Use `create(QueryResponseSchema)` to create a new message. + */ +export const QueryResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_errors_v1_errors, 7); + +/** + * Page of error messages + * + * @generated from message errors.v1.Errors + */ +export type Errors = Message<"errors.v1.Errors"> & { + /** + * @generated from field: repeated errors.v1.ErrorMessage items = 1; + */ + items: ErrorMessage[]; +}; + +/** + * Describes the message errors.v1.Errors. + * Use `create(ErrorsSchema)` to create a new message. + */ +export const ErrorsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_errors_v1_errors, 8); + +/** + * Page of component errors + * + * @generated from message errors.v1.ComponentErrors + */ +export type ComponentErrors = Message<"errors.v1.ComponentErrors"> & { + /** + * @generated from field: repeated errors.v1.ComponentError items = 1; + */ + items: ComponentError[]; +}; + +/** + * Describes the message errors.v1.ComponentErrors. + * Use `create(ComponentErrorsSchema)` to create a new message. + */ +export const ComponentErrorsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_errors_v1_errors, 9); + +/** + * Page of device errors + * + * @generated from message errors.v1.DeviceErrors + */ +export type DeviceErrors = Message<"errors.v1.DeviceErrors"> & { + /** + * @generated from field: repeated errors.v1.DeviceError items = 1; + */ + items: DeviceError[]; +}; + +/** + * Describes the message errors.v1.DeviceErrors. + * Use `create(DeviceErrorsSchema)` to create a new message. + */ +export const DeviceErrorsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_errors_v1_errors, 10); + +/** + * Get single error request + * + * @generated from message errors.v1.GetErrorRequest + */ +export type GetErrorRequest = Message<"errors.v1.GetErrorRequest"> & { + /** + * @generated from field: string error_id = 1; + */ + errorId: string; +}; + +/** + * Describes the message errors.v1.GetErrorRequest. + * Use `create(GetErrorRequestSchema)` to create a new message. + */ +export const GetErrorRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_errors_v1_errors, 11); + +/** + * Get single error response + * + * @generated from message errors.v1.GetErrorResponse + */ +export type GetErrorResponse = Message<"errors.v1.GetErrorResponse"> & { + /** + * @generated from field: errors.v1.ErrorMessage error = 1; + */ + error?: ErrorMessage; +}; + +/** + * Describes the message errors.v1.GetErrorResponse. + * Use `create(GetErrorResponseSchema)` to create a new message. + */ +export const GetErrorResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_errors_v1_errors, 12); + +/** + * List canonical errors request (no parameters) + * + * @generated from message errors.v1.ListMinerErrorsRequest + */ +export type ListMinerErrorsRequest = Message<"errors.v1.ListMinerErrorsRequest"> & {}; + +/** + * Describes the message errors.v1.ListMinerErrorsRequest. + * Use `create(ListMinerErrorsRequestSchema)` to create a new message. + */ +export const ListMinerErrorsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_errors_v1_errors, 13); + +/** + * List canonical errors response + * + * @generated from message errors.v1.ListMinerErrorsResponse + */ +export type ListMinerErrorsResponse = Message<"errors.v1.ListMinerErrorsResponse"> & { + /** + * @generated from field: repeated errors.v1.MinerErrorInfo items = 1; + */ + items: MinerErrorInfo[]; +}; + +/** + * Describes the message errors.v1.ListMinerErrorsResponse. + * Use `create(ListMinerErrorsResponseSchema)` to create a new message. + */ +export const ListMinerErrorsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_errors_v1_errors, 14); + +/** + * Metadata about a canonical error code + * + * @generated from message errors.v1.MinerErrorInfo + */ +export type MinerErrorInfo = Message<"errors.v1.MinerErrorInfo"> & { + /** + * @generated from field: errors.v1.MinerError code = 1; + */ + code: MinerError; + + /** + * @generated from field: string name = 2; + */ + name: string; + + /** + * @generated from field: string default_summary = 3; + */ + defaultSummary: string; + + /** + * @generated from field: errors.v1.Severity default_severity = 4; + */ + defaultSeverity: Severity; + + /** + * @generated from field: string default_action = 5; + */ + defaultAction: string; + + /** + * Human-readable business impact description + * + * @generated from field: string default_impact = 6; + */ + defaultImpact: string; +}; + +/** + * Describes the message errors.v1.MinerErrorInfo. + * Use `create(MinerErrorInfoSchema)` to create a new message. + */ +export const MinerErrorInfoSchema: GenMessage = /*@__PURE__*/ messageDesc(file_errors_v1_errors, 15); + +/** + * Watch request for streaming updates + * + * @generated from message errors.v1.WatchRequest + */ +export type WatchRequest = Message<"errors.v1.WatchRequest"> & { + /** + * @generated from field: errors.v1.Filter filter = 1; + */ + filter?: Filter; +}; + +/** + * Describes the message errors.v1.WatchRequest. + * Use `create(WatchRequestSchema)` to create a new message. + */ +export const WatchRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_errors_v1_errors, 16); + +/** + * Watch response for streaming updates + * + * @generated from message errors.v1.WatchResponse + */ +export type WatchResponse = Message<"errors.v1.WatchResponse"> & { + /** + * @generated from oneof errors.v1.WatchResponse.result + */ + result: + | { + /** + * @generated from field: errors.v1.Errors errors = 1; + */ + value: Errors; + case: "errors"; + } + | { + /** + * @generated from field: errors.v1.ComponentErrors components = 2; + */ + value: ComponentErrors; + case: "components"; + } + | { + /** + * @generated from field: errors.v1.DeviceErrors devices = 3; + */ + value: DeviceErrors; + case: "devices"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from field: errors.v1.WatchResponse.Kind kind = 4; + */ + kind: WatchResponse_Kind; +}; + +/** + * Describes the message errors.v1.WatchResponse. + * Use `create(WatchResponseSchema)` to create a new message. + */ +export const WatchResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_errors_v1_errors, 17); + +/** + * @generated from enum errors.v1.WatchResponse.Kind + */ +export enum WatchResponse_Kind { + /** + * @generated from enum value: KIND_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: KIND_OPENED = 1; + */ + OPENED = 1, + + /** + * @generated from enum value: KIND_UPDATED = 2; + */ + UPDATED = 2, + + /** + * @generated from enum value: KIND_CLOSED = 3; + */ + CLOSED = 3, +} + +/** + * Describes the enum errors.v1.WatchResponse.Kind. + */ +export const WatchResponse_KindSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_errors_v1_errors, 17, 0); + +/** + * Canonical, miner-agnostic error codes + * - 1xx PSU & facility power at PSU terminals + * - 2xx Thermal & fans + * - 3xx Board/ASIC chain & hash performance + * - 35x Board-level power rails & protection (distinct from PSU) + * - 4xx Sensors (electrical faults, not just out-of-range) + * - 5xx Non-volatile storage / firmware + * - 6xx Control-plane & on-board comms (hardware-side) + * - 8xx Performance advisories (non-fatal) + * - 9xx Catch-alls / vendor-unknown + * + * @generated from enum errors.v1.MinerError + */ +export enum MinerError { + /** + * @generated from enum value: MINER_ERROR_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * 1000–1049: PSU (unit-level faults) + * + * @generated from enum value: MINER_ERROR_PSU_NOT_PRESENT = 1000; + */ + PSU_NOT_PRESENT = 1000, + + /** + * @generated from enum value: MINER_ERROR_PSU_MODEL_MISMATCH = 1001; + */ + PSU_MODEL_MISMATCH = 1001, + + /** + * @generated from enum value: MINER_ERROR_PSU_COMMUNICATION_LOST = 1002; + */ + PSU_COMMUNICATION_LOST = 1002, + + /** + * @generated from enum value: MINER_ERROR_PSU_FAULT_GENERIC = 1003; + */ + PSU_FAULT_GENERIC = 1003, + + /** + * @generated from enum value: MINER_ERROR_PSU_INPUT_VOLTAGE_LOW = 1004; + */ + PSU_INPUT_VOLTAGE_LOW = 1004, + + /** + * @generated from enum value: MINER_ERROR_PSU_INPUT_VOLTAGE_HIGH = 1005; + */ + PSU_INPUT_VOLTAGE_HIGH = 1005, + + /** + * @generated from enum value: MINER_ERROR_PSU_OUTPUT_VOLTAGE_FAULT = 1006; + */ + PSU_OUTPUT_VOLTAGE_FAULT = 1006, + + /** + * @generated from enum value: MINER_ERROR_PSU_OUTPUT_OVERCURRENT = 1007; + */ + PSU_OUTPUT_OVERCURRENT = 1007, + + /** + * @generated from enum value: MINER_ERROR_PSU_FAN_FAILED = 1008; + */ + PSU_FAN_FAILED = 1008, + + /** + * @generated from enum value: MINER_ERROR_PSU_OVER_TEMPERATURE = 1009; + */ + PSU_OVER_TEMPERATURE = 1009, + + /** + * @generated from enum value: MINER_ERROR_PSU_INPUT_PHASE_IMBALANCE = 1010; + */ + PSU_INPUT_PHASE_IMBALANCE = 1010, + + /** + * @generated from enum value: MINER_ERROR_PSU_UNDER_TEMPERATURE = 1011; + */ + PSU_UNDER_TEMPERATURE = 1011, + + /** + * 2000–2029: Thermal & fans (device-level) + * + * @generated from enum value: MINER_ERROR_FAN_FAILED = 2000; + */ + FAN_FAILED = 2000, + + /** + * @generated from enum value: MINER_ERROR_FAN_TACH_SIGNAL_LOST = 2001; + */ + FAN_TACH_SIGNAL_LOST = 2001, + + /** + * @generated from enum value: MINER_ERROR_FAN_SPEED_DEVIATION = 2002; + */ + FAN_SPEED_DEVIATION = 2002, + + /** + * @generated from enum value: MINER_ERROR_INLET_OVER_TEMPERATURE = 2010; + */ + INLET_OVER_TEMPERATURE = 2010, + + /** + * @generated from enum value: MINER_ERROR_DEVICE_OVER_TEMPERATURE = 2011; + */ + DEVICE_OVER_TEMPERATURE = 2011, + + /** + * @generated from enum value: MINER_ERROR_DEVICE_UNDER_TEMPERATURE = 2012; + */ + DEVICE_UNDER_TEMPERATURE = 2012, + + /** + * 3000–3049: Hashboard / ASIC chain & core digital + * + * @generated from enum value: MINER_ERROR_HASHBOARD_NOT_PRESENT = 3000; + */ + HASHBOARD_NOT_PRESENT = 3000, + + /** + * @generated from enum value: MINER_ERROR_HASHBOARD_OVER_TEMPERATURE = 3001; + */ + HASHBOARD_OVER_TEMPERATURE = 3001, + + /** + * @generated from enum value: MINER_ERROR_HASHBOARD_MISSING_CHIPS = 3002; + */ + HASHBOARD_MISSING_CHIPS = 3002, + + /** + * @generated from enum value: MINER_ERROR_ASIC_CHAIN_COMMUNICATION_LOST = 3003; + */ + ASIC_CHAIN_COMMUNICATION_LOST = 3003, + + /** + * @generated from enum value: MINER_ERROR_ASIC_CLOCK_PLL_UNLOCKED = 3004; + */ + ASIC_CLOCK_PLL_UNLOCKED = 3004, + + /** + * @generated from enum value: MINER_ERROR_ASIC_CRC_ERROR_EXCESSIVE = 3005; + */ + ASIC_CRC_ERROR_EXCESSIVE = 3005, + + /** + * @generated from enum value: MINER_ERROR_HASHBOARD_ASIC_OVER_TEMPERATURE = 3006; + */ + HASHBOARD_ASIC_OVER_TEMPERATURE = 3006, + + /** + * @generated from enum value: MINER_ERROR_HASHBOARD_ASIC_UNDER_TEMPERATURE = 3007; + */ + HASHBOARD_ASIC_UNDER_TEMPERATURE = 3007, + + /** + * 3500–3699: Board-level power rails & protection (distinct from PSU) + * + * @generated from enum value: MINER_ERROR_BOARD_POWER_PGOOD_MISSING = 3500; + */ + BOARD_POWER_PGOOD_MISSING = 3500, + + /** + * @generated from enum value: MINER_ERROR_BOARD_POWER_OVERCURRENT_TRIP = 3501; + */ + BOARD_POWER_OVERCURRENT_TRIP = 3501, + + /** + * @generated from enum value: MINER_ERROR_BOARD_POWER_RAIL_UNDERVOLT = 3502; + */ + BOARD_POWER_RAIL_UNDERVOLT = 3502, + + /** + * @generated from enum value: MINER_ERROR_BOARD_POWER_RAIL_OVERVOLT = 3503; + */ + BOARD_POWER_RAIL_OVERVOLT = 3503, + + /** + * @generated from enum value: MINER_ERROR_BOARD_POWER_SHORT_DETECTED = 3504; + */ + BOARD_POWER_SHORT_DETECTED = 3504, + + /** + * 4000–4029: Sensors (electrical faults) + * + * @generated from enum value: MINER_ERROR_TEMP_SENSOR_OPEN_OR_SHORT = 4000; + */ + TEMP_SENSOR_OPEN_OR_SHORT = 4000, + + /** + * @generated from enum value: MINER_ERROR_TEMP_SENSOR_FAULT = 4001; + */ + TEMP_SENSOR_FAULT = 4001, + + /** + * @generated from enum value: MINER_ERROR_VOLTAGE_SENSOR_FAULT = 4002; + */ + VOLTAGE_SENSOR_FAULT = 4002, + + /** + * @generated from enum value: MINER_ERROR_CURRENT_SENSOR_FAULT = 4003; + */ + CURRENT_SENSOR_FAULT = 4003, + + /** + * 5000–5499: Non-volatile storage / firmware (hardware-adjacent) + * + * @generated from enum value: MINER_ERROR_EEPROM_CRC_MISMATCH = 5000; + */ + EEPROM_CRC_MISMATCH = 5000, + + /** + * @generated from enum value: MINER_ERROR_EEPROM_READ_FAILURE = 5001; + */ + EEPROM_READ_FAILURE = 5001, + + /** + * @generated from enum value: MINER_ERROR_FIRMWARE_IMAGE_INVALID = 5002; + */ + FIRMWARE_IMAGE_INVALID = 5002, + + /** + * @generated from enum value: MINER_ERROR_FIRMWARE_CONFIG_INVALID = 5003; + */ + FIRMWARE_CONFIG_INVALID = 5003, + + /** + * 6000–6499: Control-plane & on-board comms (hardware-side) + * + * @generated from enum value: MINER_ERROR_CONTROL_BOARD_COMMUNICATION_LOST = 6000; + */ + CONTROL_BOARD_COMMUNICATION_LOST = 6000, + + /** + * @generated from enum value: MINER_ERROR_CONTROL_BOARD_FAILURE = 6001; + */ + CONTROL_BOARD_FAILURE = 6001, + + /** + * @generated from enum value: MINER_ERROR_DEVICE_INTERNAL_BUS_FAULT = 6002; + */ + DEVICE_INTERNAL_BUS_FAULT = 6002, + + /** + * @generated from enum value: MINER_ERROR_DEVICE_COMMUNICATION_LOST = 6003; + */ + DEVICE_COMMUNICATION_LOST = 6003, + + /** + * @generated from enum value: MINER_ERROR_IO_MODULE_FAILURE = 6010; + */ + IO_MODULE_FAILURE = 6010, + + /** + * 8000–8029: Performance advisories (non-fatal canonical warnings) + * + * @generated from enum value: MINER_ERROR_HASHRATE_BELOW_TARGET = 8000; + */ + HASHRATE_BELOW_TARGET = 8000, + + /** + * @generated from enum value: MINER_ERROR_HASHBOARD_WARN_CRC_HIGH = 8001; + */ + HASHBOARD_WARN_CRC_HIGH = 8001, + + /** + * @generated from enum value: MINER_ERROR_THERMAL_MARGIN_LOW = 8002; + */ + THERMAL_MARGIN_LOW = 8002, + + /** + * 9000–9029: Catch-alls + * + * @generated from enum value: MINER_ERROR_VENDOR_ERROR_UNMAPPED = 9000; + */ + VENDOR_ERROR_UNMAPPED = 9000, +} + +/** + * Describes the enum errors.v1.MinerError. + */ +export const MinerErrorSchema: GenEnum = /*@__PURE__*/ enumDesc(file_errors_v1_errors, 0); + +/** + * Severity classification for errors + * + * @generated from enum errors.v1.Severity + */ +export enum Severity { + /** + * @generated from enum value: SEVERITY_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Miner stops hashing or unsafe + * + * @generated from enum value: SEVERITY_CRITICAL = 1; + */ + CRITICAL = 1, + + /** + * Degraded hashing / imminent trip + * + * @generated from enum value: SEVERITY_MAJOR = 2; + */ + MAJOR = 2, + + /** + * Recoverable, limited effect + * + * @generated from enum value: SEVERITY_MINOR = 3; + */ + MINOR = 3, + + /** + * Informational / advisory + * + * @generated from enum value: SEVERITY_INFO = 4; + */ + INFO = 4, +} + +/** + * Describes the enum errors.v1.Severity. + */ +export const SeveritySchema: GenEnum = /*@__PURE__*/ enumDesc(file_errors_v1_errors, 1); + +/** + * Aggregated status based on error severity waterfall + * ERROR if any CRITICAL, WARNING if any MAJOR/MINOR/INFO, else OK + * + * @generated from enum errors.v1.Status + */ +export enum Status { + /** + * @generated from enum value: STATUS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * No open errors + * + * @generated from enum value: STATUS_OK = 1; + */ + OK = 1, + + /** + * Major, minor, or info severity errors present + * + * @generated from enum value: STATUS_WARNING = 2; + */ + WARNING = 2, + + /** + * Critical severity errors present + * + * @generated from enum value: STATUS_ERROR = 3; + */ + ERROR = 3, +} + +/** + * Describes the enum errors.v1.Status. + */ +export const StatusSchema: GenEnum = /*@__PURE__*/ enumDesc(file_errors_v1_errors, 2); + +/** + * Type of component that can have errors + * + * @generated from enum errors.v1.ComponentType + */ +export enum ComponentType { + /** + * @generated from enum value: COMPONENT_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: COMPONENT_TYPE_PSU = 1; + */ + PSU = 1, + + /** + * @generated from enum value: COMPONENT_TYPE_HASH_BOARD = 2; + */ + HASH_BOARD = 2, + + /** + * @generated from enum value: COMPONENT_TYPE_FAN = 3; + */ + FAN = 3, + + /** + * @generated from enum value: COMPONENT_TYPE_CONTROL_BOARD = 4; + */ + CONTROL_BOARD = 4, + + /** + * @generated from enum value: COMPONENT_TYPE_EEPROM = 5; + */ + EEPROM = 5, + + /** + * @generated from enum value: COMPONENT_TYPE_IO_MODULE = 6; + */ + IO_MODULE = 6, +} + +/** + * Describes the enum errors.v1.ComponentType. + */ +export const ComponentTypeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_errors_v1_errors, 3); + +/** + * Result view determines how errors are aggregated in the response + * + * @generated from enum errors.v1.ResultView + */ +export enum ResultView { + /** + * @generated from enum value: RESULT_VIEW_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Flat list of individual errors + * + * @generated from enum value: RESULT_VIEW_ERROR = 1; + */ + ERROR = 1, + + /** + * Grouped by component + * + * @generated from enum value: RESULT_VIEW_COMPONENT = 2; + */ + COMPONENT = 2, + + /** + * Grouped by device + * + * @generated from enum value: RESULT_VIEW_DEVICE = 3; + */ + DEVICE = 3, +} + +/** + * Describes the enum errors.v1.ResultView. + */ +export const ResultViewSchema: GenEnum = /*@__PURE__*/ enumDesc(file_errors_v1_errors, 4); + +/** + * Global logic for combining filter criteria. + * TODO: Currently only AND logic is implemented. OR support planned. + * + * @generated from enum errors.v1.GlobalLogic + */ +export enum GlobalLogic { + /** + * @generated from enum value: GLOBAL_LOGIC_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * All criteria must match (default, currently only mode) + * + * @generated from enum value: GLOBAL_LOGIC_AND = 1; + */ + AND = 1, + + /** + * Any criterion can match (not yet implemented) + * + * @generated from enum value: GLOBAL_LOGIC_OR = 2; + */ + OR = 2, +} + +/** + * Describes the enum errors.v1.GlobalLogic. + */ +export const GlobalLogicSchema: GenEnum = /*@__PURE__*/ enumDesc(file_errors_v1_errors, 5); + +/** + * ErrorQueryService provides querying capabilities for miner errors + * + * @generated from service errors.v1.ErrorQueryService + */ +export const ErrorQueryService: GenService<{ + /** + * Query errors with filtering, pagination, and result view options + * + * @generated from rpc errors.v1.ErrorQueryService.Query + */ + query: { + methodKind: "unary"; + input: typeof QueryRequestSchema; + output: typeof QueryResponseSchema; + }; + /** + * Get a single error by ID + * + * @generated from rpc errors.v1.ErrorQueryService.GetError + */ + getError: { + methodKind: "unary"; + input: typeof GetErrorRequestSchema; + output: typeof GetErrorResponseSchema; + }; + /** + * List all canonical error definitions with metadata + * + * @generated from rpc errors.v1.ErrorQueryService.ListMinerErrors + */ + listMinerErrors: { + methodKind: "unary"; + input: typeof ListMinerErrorsRequestSchema; + output: typeof ListMinerErrorsResponseSchema; + }; + /** + * Watch for real-time error updates (streaming) + * + * @generated from rpc errors.v1.ErrorQueryService.Watch + */ + watch: { + methodKind: "server_streaming"; + input: typeof WatchRequestSchema; + output: typeof WatchResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_errors_v1_errors, 0); diff --git a/client/src/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb.ts b/client/src/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb.ts new file mode 100644 index 000000000..80aa2ec5e --- /dev/null +++ b/client/src/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb.ts @@ -0,0 +1,1620 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file fleetmanagement/v1/fleetmanagement.proto (package fleetmanagement.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import type { Measurement } from "../../common/v1/measurement_pb"; +import { file_common_v1_measurement } from "../../common/v1/measurement_pb"; +import type { CoolingMode } from "../../common/v1/cooling_pb"; +import { file_common_v1_cooling } from "../../common/v1/cooling_pb"; +import type { MinerStateCounts, TemperatureStatus } from "../../telemetry/v1/telemetry_pb"; +import { file_telemetry_v1_telemetry } from "../../telemetry/v1/telemetry_pb"; +import type { MinerCapabilities } from "../../capabilities/v1/capabilities_pb"; +import { file_capabilities_v1_capabilities } from "../../capabilities/v1/capabilities_pb"; +import type { ComponentType } from "../../errors/v1/errors_pb"; +import { file_errors_v1_errors } from "../../errors/v1/errors_pb"; +import type { DeviceIdentifierList } from "../../common/v1/device_selector_pb"; +import { file_common_v1_device_selector } from "../../common/v1/device_selector_pb"; +import type { SortConfig } from "../../common/v1/sort_pb"; +import { file_common_v1_sort } from "../../common/v1/sort_pb"; +import { file_buf_validate_validate } from "../../buf/validate/validate_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file fleetmanagement/v1/fleetmanagement.proto. + */ +export const file_fleetmanagement_v1_fleetmanagement: GenFile = + /*@__PURE__*/ + fileDesc( + "CihmbGVldG1hbmFnZW1lbnQvdjEvZmxlZXRtYW5hZ2VtZW50LnByb3RvEhJmbGVldG1hbmFnZW1lbnQudjEiigYKEk1pbmVyU3RhdGVTbmFwc2hvdBIZChFkZXZpY2VfaWRlbnRpZmllchgBIAEoCRIMCgRuYW1lGAIgASgJEhMKC21hY19hZGRyZXNzGAMgASgJEhUKDXNlcmlhbF9udW1iZXIYBCABKAkSKwoLcG93ZXJfdXNhZ2UYBSADKAsyFi5jb21tb24udjEuTWVhc3VyZW1lbnQSKwoLdGVtcGVyYXR1cmUYBiADKAsyFi5jb21tb24udjEuTWVhc3VyZW1lbnQSKAoIaGFzaHJhdGUYByADKAsyFi5jb21tb24udjEuTWVhc3VyZW1lbnQSKgoKZWZmaWNpZW5jeRgIIAMoCzIWLmNvbW1vbi52MS5NZWFzdXJlbWVudBItCgl0aW1lc3RhbXAYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhIKCmlwX2FkZHJlc3MYCiABKAkSCwoDdXJsGAsgASgJEjcKDWRldmljZV9zdGF0dXMYDCABKA4yIC5mbGVldG1hbmFnZW1lbnQudjEuRGV2aWNlU3RhdHVzEjkKDnBhaXJpbmdfc3RhdHVzGA0gASgOMiEuZmxlZXRtYW5hZ2VtZW50LnYxLlBhaXJpbmdTdGF0dXMSDQoFbW9kZWwYDiABKAkSFAoMbWFudWZhY3R1cmVyGA8gASgJEjgKDGNhcGFiaWxpdGllcxgRIAEoCzIiLmNhcGFiaWxpdGllcy52MS5NaW5lckNhcGFiaWxpdGllcxI7ChJ0ZW1wZXJhdHVyZV9zdGF0dXMYEiABKA4yHy50ZWxlbWV0cnkudjEuVGVtcGVyYXR1cmVTdGF0dXMSGAoQZmlybXdhcmVfdmVyc2lvbhgTIAEoCRIUCgxncm91cF9sYWJlbHMYFCADKAkSEgoKcmFja19sYWJlbBgVIAEoCRITCgtkcml2ZXJfbmFtZRgWIAEoCRITCgt3b3JrZXJfbmFtZRgXIAEoCRIVCg1yYWNrX3Bvc2l0aW9uGBggASgJSgQIEBARUgR0eXBlIqkBCh5MaXN0TWluZXJTdGF0ZVNuYXBzaG90c1JlcXVlc3QSHQoJcGFnZV9zaXplGAEgASgFQgq6SAcaBRjoBygAEg4KBmN1cnNvchgCIAEoCRIzCgZmaWx0ZXIYAyABKAsyIy5mbGVldG1hbmFnZW1lbnQudjEuTWluZXJMaXN0RmlsdGVyEiMKBHNvcnQYBCADKAsyFS5jb21tb24udjEuU29ydENvbmZpZyL1AQoPTWluZXJMaXN0RmlsdGVyEjcKDWRldmljZV9zdGF0dXMYAyADKA4yIC5mbGVldG1hbmFnZW1lbnQudjEuRGV2aWNlU3RhdHVzEjcKFWVycm9yX2NvbXBvbmVudF90eXBlcxgEIAMoDjIYLmVycm9ycy52MS5Db21wb25lbnRUeXBlEg4KBm1vZGVscxgFIAMoCRI7ChBwYWlyaW5nX3N0YXR1c2VzGAYgAygOMiEuZmxlZXRtYW5hZ2VtZW50LnYxLlBhaXJpbmdTdGF0dXMSEQoJZ3JvdXBfaWRzGAcgAygDEhAKCHJhY2tfaWRzGAggAygDIssBCh9MaXN0TWluZXJTdGF0ZVNuYXBzaG90c1Jlc3BvbnNlEjYKBm1pbmVycxgBIAMoCzImLmZsZWV0bWFuYWdlbWVudC52MS5NaW5lclN0YXRlU25hcHNob3QSDgoGY3Vyc29yGAIgASgJEhQKDHRvdGFsX21pbmVycxgDIAEoBRI6ChJ0b3RhbF9zdGF0ZV9jb3VudHMYBCABKAsyHi50ZWxlbWV0cnkudjEuTWluZXJTdGF0ZUNvdW50cxIOCgZtb2RlbHMYBSADKAkikgEKGUV4cG9ydE1pbmVyTGlzdENzdlJlcXVlc3QSMwoGZmlsdGVyGAEgASgLMiMuZmxlZXRtYW5hZ2VtZW50LnYxLk1pbmVyTGlzdEZpbHRlchJAChB0ZW1wZXJhdHVyZV91bml0GAIgASgOMiYuZmxlZXRtYW5hZ2VtZW50LnYxLkNzdlRlbXBlcmF0dXJlVW5pdCIuChpFeHBvcnRNaW5lckxpc3RDc3ZSZXNwb25zZRIQCghjc3ZfZGF0YRgBIAEoDCIcChpHZXRNaW5lclN0YXRlQ291bnRzUmVxdWVzdCJpChtHZXRNaW5lclN0YXRlQ291bnRzUmVzcG9uc2USFAoMdG90YWxfbWluZXJzGAEgASgFEjQKDHN0YXRlX2NvdW50cxgCIAEoCzIeLnRlbGVtZXRyeS52MS5NaW5lclN0YXRlQ291bnRzIkMKHkdldE1pbmVyUG9vbEFzc2lnbm1lbnRzUmVxdWVzdBIhChFkZXZpY2VfaWRlbnRpZmllchgBIAEoCUIGukgDyAEBIlEKDlBvb2xBc3NpZ25tZW50EhQKB3Bvb2xfaWQYASABKANIAIgBARILCgN1cmwYAiABKAkSEAoIdXNlcm5hbWUYAyABKAlCCgoIX3Bvb2xfaWQiVAofR2V0TWluZXJQb29sQXNzaWdubWVudHNSZXNwb25zZRIxCgVwb29scxgBIAMoCzIiLmZsZWV0bWFuYWdlbWVudC52MS5Qb29sQXNzaWdubWVudCJFCg9NaW5lck1vZGVsR3JvdXASDQoFbW9kZWwYASABKAkSFAoMbWFudWZhY3R1cmVyGAIgASgJEg0KBWNvdW50GAMgASgFIlEKGkdldE1pbmVyTW9kZWxHcm91cHNSZXF1ZXN0EjMKBmZpbHRlchgBIAEoCzIjLmZsZWV0bWFuYWdlbWVudC52MS5NaW5lckxpc3RGaWx0ZXIiUgobR2V0TWluZXJNb2RlbEdyb3Vwc1Jlc3BvbnNlEjMKBmdyb3VwcxgBIAMoCzIjLmZsZWV0bWFuYWdlbWVudC52MS5NaW5lck1vZGVsR3JvdXAiPwoaR2V0TWluZXJDb29saW5nTW9kZVJlcXVlc3QSIQoRZGV2aWNlX2lkZW50aWZpZXIYASABKAlCBrpIA8gBASJLChtHZXRNaW5lckNvb2xpbmdNb2RlUmVzcG9uc2USLAoMY29vbGluZ19tb2RlGAEgASgOMhYuY29tbW9uLnYxLkNvb2xpbmdNb2RlIpoBCg5EZXZpY2VTZWxlY3RvchI6CgthbGxfZGV2aWNlcxgBIAEoCzIjLmZsZWV0bWFuYWdlbWVudC52MS5NaW5lckxpc3RGaWx0ZXJIABI6Cg9pbmNsdWRlX2RldmljZXMYAiABKAsyHy5jb21tb24udjEuRGV2aWNlSWRlbnRpZmllckxpc3RIAEIQCg5zZWxlY3Rpb25fdHlwZSJSChNEZWxldGVNaW5lcnNSZXF1ZXN0EjsKD2RldmljZV9zZWxlY3RvchgBIAEoCzIiLmZsZWV0bWFuYWdlbWVudC52MS5EZXZpY2VTZWxlY3RvciItChREZWxldGVNaW5lcnNSZXNwb25zZRIVCg1kZWxldGVkX2NvdW50GAEgASgFIsEBChNSZW5hbWVNaW5lcnNSZXF1ZXN0EkMKD2RldmljZV9zZWxlY3RvchgBIAEoCzIiLmZsZWV0bWFuYWdlbWVudC52MS5EZXZpY2VTZWxlY3RvckIGukgDyAEBEkAKC25hbWVfY29uZmlnGAIgASgLMiMuZmxlZXRtYW5hZ2VtZW50LnYxLk1pbmVyTmFtZUNvbmZpZ0IGukgDyAEBEiMKBHNvcnQYAyADKAsyFS5jb21tb24udjEuU29ydENvbmZpZyJcChRSZW5hbWVNaW5lcnNSZXNwb25zZRIVCg1yZW5hbWVkX2NvdW50GAEgASgFEhcKD3VuY2hhbmdlZF9jb3VudBgCIAEoBRIUCgxmYWlsZWRfY291bnQYAyABKAUihgIKGFVwZGF0ZVdvcmtlck5hbWVzUmVxdWVzdBJDCg9kZXZpY2Vfc2VsZWN0b3IYASABKAsyIi5mbGVldG1hbmFnZW1lbnQudjEuRGV2aWNlU2VsZWN0b3JCBrpIA8gBARJACgtuYW1lX2NvbmZpZxgCIAEoCzIjLmZsZWV0bWFuYWdlbWVudC52MS5NaW5lck5hbWVDb25maWdCBrpIA8gBARIjCgRzb3J0GAMgAygLMhUuY29tbW9uLnYxLlNvcnRDb25maWcSHgoNdXNlcl91c2VybmFtZRgEIAEoCUIHukgEcgIQARIeCg11c2VyX3Bhc3N3b3JkGAUgASgJQge6SARyAhABInsKGVVwZGF0ZVdvcmtlck5hbWVzUmVzcG9uc2USFQoNdXBkYXRlZF9jb3VudBgBIAEoBRIXCg91bmNoYW5nZWRfY291bnQYAiABKAUSFAoMZmFpbGVkX2NvdW50GAMgASgFEhgKEGJhdGNoX2lkZW50aWZpZXIYBCABKAkidgoPTWluZXJOYW1lQ29uZmlnEj4KCnByb3BlcnRpZXMYASADKAsyIC5mbGVldG1hbmFnZW1lbnQudjEuTmFtZVByb3BlcnR5Qgi6SAWSAQIIARIjCglzZXBhcmF0b3IYAiABKAlCELpIDXILUgEtUgFfUgEuUgAi0QIKDE5hbWVQcm9wZXJ0eRJKChJzdHJpbmdfYW5kX2NvdW50ZXIYASABKAsyLC5mbGVldG1hbmFnZW1lbnQudjEuU3RyaW5nQW5kQ291bnRlclByb3BlcnR5SAASNgoHY291bnRlchgCIAEoCzIjLmZsZWV0bWFuYWdlbWVudC52MS5Db3VudGVyUHJvcGVydHlIABI6CgxzdHJpbmdfdmFsdWUYAyABKAsyIi5mbGVldG1hbmFnZW1lbnQudjEuU3RyaW5nUHJvcGVydHlIABI9CgtmaXhlZF92YWx1ZRgEIAEoCzImLmZsZWV0bWFuYWdlbWVudC52MS5GaXhlZFZhbHVlUHJvcGVydHlIABI6CglxdWFsaWZpZXIYBSABKAsyJS5mbGVldG1hbmFnZW1lbnQudjEuUXVhbGlmaWVyUHJvcGVydHlIAEIGCgRraW5kInwKGFN0cmluZ0FuZENvdW50ZXJQcm9wZXJ0eRIOCgZwcmVmaXgYASABKAkSDgoGc3VmZml4GAIgASgJEh4KDWNvdW50ZXJfc3RhcnQYAyABKAVCB7pIBBoCKAASIAoNY291bnRlcl9zY2FsZRgEIAEoBUIJukgGGgQYBigBIlMKD0NvdW50ZXJQcm9wZXJ0eRIeCg1jb3VudGVyX3N0YXJ0GAEgASgFQge6SAQaAigAEiAKDWNvdW50ZXJfc2NhbGUYAiABKAVCCbpIBhoEGAYoASIoCg5TdHJpbmdQcm9wZXJ0eRIWCgV2YWx1ZRgBIAEoCUIHukgEcgIQASLyAgoSRml4ZWRWYWx1ZVByb3BlcnR5EjoKBHR5cGUYASABKA4yIi5mbGVldG1hbmFnZW1lbnQudjEuRml4ZWRWYWx1ZVR5cGVCCLpIBYIBAhABEicKD2NoYXJhY3Rlcl9jb3VudBgCIAEoBUIJukgGGgQYBigBSACIAQESRAoHc2VjdGlvbhgDIAEoDjIkLmZsZWV0bWFuYWdlbWVudC52MS5DaGFyYWN0ZXJTZWN0aW9uQgi6SAWCAQIQAUgBiAEBOpABukiMARqJAQolc2VjdGlvbl9yZXF1aXJlZF93aXRoX2NoYXJhY3Rlcl9jb3VudBIvc2VjdGlvbiBpcyByZXF1aXJlZCB3aGVuIGNoYXJhY3Rlcl9jb3VudCBpcyBzZXQaLyFoYXModGhpcy5jaGFyYWN0ZXJfY291bnQpIHx8IGhhcyh0aGlzLnNlY3Rpb24pQhIKEF9jaGFyYWN0ZXJfY291bnRCCgoIX3NlY3Rpb24ibgoRUXVhbGlmaWVyUHJvcGVydHkSOQoEdHlwZRgBIAEoDjIhLmZsZWV0bWFuYWdlbWVudC52MS5RdWFsaWZpZXJUeXBlQgi6SAWCAQIQARIOCgZwcmVmaXgYAiABKAkSDgoGc3VmZml4GAMgASgJKpkBCh9GbGVldE1hbmFnZW1lbnRTZXJ2aWNlRXJyb3JDb2RlEjMKL0ZMRUVUX01BTkFHRU1FTlRfU0VSVklDRV9FUlJPUl9DT0RFX1VOU1BFQ0lGSUVEEAASQQo9RkxFRVRfTUFOQUdFTUVOVF9TRVJWSUNFX0VSUk9SX0NPREVfSU5WQUxJRF9QQUdJTkFUSU9OX0NVUlNPUhABKpoCCgxEZXZpY2VTdGF0dXMSHQoZREVWSUNFX1NUQVRVU19VTlNQRUNJRklFRBAAEhgKFERFVklDRV9TVEFUVVNfT05MSU5FEAESGQoVREVWSUNFX1NUQVRVU19PRkZMSU5FEAISHQoZREVWSUNFX1NUQVRVU19NQUlOVEVOQU5DRRADEhcKE0RFVklDRV9TVEFUVVNfRVJST1IQBBIaChZERVZJQ0VfU1RBVFVTX0lOQUNUSVZFEAUSIwofREVWSUNFX1NUQVRVU19ORUVEU19NSU5JTkdfUE9PTBAGEhoKFkRFVklDRV9TVEFUVVNfVVBEQVRJTkcQBxIhCh1ERVZJQ0VfU1RBVFVTX1JFQk9PVF9SRVFVSVJFRBAIKsgBCg1QYWlyaW5nU3RhdHVzEh4KGlBBSVJJTkdfU1RBVFVTX1VOU1BFQ0lGSUVEEAASGQoVUEFJUklOR19TVEFUVVNfUEFJUkVEEAESGwoXUEFJUklOR19TVEFUVVNfVU5QQUlSRUQQAhIoCiRQQUlSSU5HX1NUQVRVU19BVVRIRU5USUNBVElPTl9ORUVERUQQAxIaChZQQUlSSU5HX1NUQVRVU19QRU5ESU5HEAQSGQoVUEFJUklOR19TVEFUVVNfRkFJTEVEEAUqgQEKEkNzdlRlbXBlcmF0dXJlVW5pdBIkCiBDU1ZfVEVNUEVSQVRVUkVfVU5JVF9VTlNQRUNJRklFRBAAEiAKHENTVl9URU1QRVJBVFVSRV9VTklUX0NFTFNJVVMQARIjCh9DU1ZfVEVNUEVSQVRVUkVfVU5JVF9GQUhSRU5IRUlUEAIqmQIKDkZpeGVkVmFsdWVUeXBlEiAKHEZJWEVEX1ZBTFVFX1RZUEVfVU5TUEVDSUZJRUQQABIgChxGSVhFRF9WQUxVRV9UWVBFX01BQ19BRERSRVNTEAESIgoeRklYRURfVkFMVUVfVFlQRV9TRVJJQUxfTlVNQkVSEAISIAocRklYRURfVkFMVUVfVFlQRV9XT1JLRVJfTkFNRRADEhoKFkZJWEVEX1ZBTFVFX1RZUEVfTU9ERUwQBBIhCh1GSVhFRF9WQUxVRV9UWVBFX01BTlVGQUNUVVJFUhAFEh0KGUZJWEVEX1ZBTFVFX1RZUEVfTE9DQVRJT04QBhIfChtGSVhFRF9WQUxVRV9UWVBFX01JTkVSX05BTUUQBypuChBDaGFyYWN0ZXJTZWN0aW9uEiEKHUNIQVJBQ1RFUl9TRUNUSU9OX1VOU1BFQ0lGSUVEEAASGwoXQ0hBUkFDVEVSX1NFQ1RJT05fRklSU1QQARIaChZDSEFSQUNURVJfU0VDVElPTl9MQVNUEAIqhwEKDVF1YWxpZmllclR5cGUSHgoaUVVBTElGSUVSX1RZUEVfVU5TUEVDSUZJRUQQABIbChdRVUFMSUZJRVJfVFlQRV9CVUlMRElORxABEhcKE1FVQUxJRklFUl9UWVBFX1JBQ0sQAhIgChxRVUFMSUZJRVJfVFlQRV9SQUNLX1BPU0lUSU9OEAMyuQgKFkZsZWV0TWFuYWdlbWVudFNlcnZpY2USggEKF0xpc3RNaW5lclN0YXRlU25hcHNob3RzEjIuZmxlZXRtYW5hZ2VtZW50LnYxLkxpc3RNaW5lclN0YXRlU25hcHNob3RzUmVxdWVzdBozLmZsZWV0bWFuYWdlbWVudC52MS5MaXN0TWluZXJTdGF0ZVNuYXBzaG90c1Jlc3BvbnNlEnUKEkV4cG9ydE1pbmVyTGlzdENzdhItLmZsZWV0bWFuYWdlbWVudC52MS5FeHBvcnRNaW5lckxpc3RDc3ZSZXF1ZXN0Gi4uZmxlZXRtYW5hZ2VtZW50LnYxLkV4cG9ydE1pbmVyTGlzdENzdlJlc3BvbnNlMAESdgoTR2V0TWluZXJTdGF0ZUNvdW50cxIuLmZsZWV0bWFuYWdlbWVudC52MS5HZXRNaW5lclN0YXRlQ291bnRzUmVxdWVzdBovLmZsZWV0bWFuYWdlbWVudC52MS5HZXRNaW5lclN0YXRlQ291bnRzUmVzcG9uc2USggEKF0dldE1pbmVyUG9vbEFzc2lnbm1lbnRzEjIuZmxlZXRtYW5hZ2VtZW50LnYxLkdldE1pbmVyUG9vbEFzc2lnbm1lbnRzUmVxdWVzdBozLmZsZWV0bWFuYWdlbWVudC52MS5HZXRNaW5lclBvb2xBc3NpZ25tZW50c1Jlc3BvbnNlEnYKE0dldE1pbmVyQ29vbGluZ01vZGUSLi5mbGVldG1hbmFnZW1lbnQudjEuR2V0TWluZXJDb29saW5nTW9kZVJlcXVlc3QaLy5mbGVldG1hbmFnZW1lbnQudjEuR2V0TWluZXJDb29saW5nTW9kZVJlc3BvbnNlEmEKDERlbGV0ZU1pbmVycxInLmZsZWV0bWFuYWdlbWVudC52MS5EZWxldGVNaW5lcnNSZXF1ZXN0GiguZmxlZXRtYW5hZ2VtZW50LnYxLkRlbGV0ZU1pbmVyc1Jlc3BvbnNlEnYKE0dldE1pbmVyTW9kZWxHcm91cHMSLi5mbGVldG1hbmFnZW1lbnQudjEuR2V0TWluZXJNb2RlbEdyb3Vwc1JlcXVlc3QaLy5mbGVldG1hbmFnZW1lbnQudjEuR2V0TWluZXJNb2RlbEdyb3Vwc1Jlc3BvbnNlEmEKDFJlbmFtZU1pbmVycxInLmZsZWV0bWFuYWdlbWVudC52MS5SZW5hbWVNaW5lcnNSZXF1ZXN0GiguZmxlZXRtYW5hZ2VtZW50LnYxLlJlbmFtZU1pbmVyc1Jlc3BvbnNlEnAKEVVwZGF0ZVdvcmtlck5hbWVzEiwuZmxlZXRtYW5hZ2VtZW50LnYxLlVwZGF0ZVdvcmtlck5hbWVzUmVxdWVzdBotLmZsZWV0bWFuYWdlbWVudC52MS5VcGRhdGVXb3JrZXJOYW1lc1Jlc3BvbnNlQvABChZjb20uZmxlZXRtYW5hZ2VtZW50LnYxQhRGbGVldG1hbmFnZW1lbnRQcm90b1ABWldnaXRodWIuY29tL2Jsb2NrL3Byb3RvLWZsZWV0L3NlcnZlci9nZW5lcmF0ZWQvZ3JwYy9mbGVldG1hbmFnZW1lbnQvdjE7ZmxlZXRtYW5hZ2VtZW50djGiAgNGWFiqAhJGbGVldG1hbmFnZW1lbnQuVjHKAhJGbGVldG1hbmFnZW1lbnRcVjHiAh5GbGVldG1hbmFnZW1lbnRcVjFcR1BCTWV0YWRhdGHqAhNGbGVldG1hbmFnZW1lbnQ6OlYxYgZwcm90bzM", + [ + file_google_protobuf_timestamp, + file_common_v1_measurement, + file_common_v1_cooling, + file_telemetry_v1_telemetry, + file_capabilities_v1_capabilities, + file_errors_v1_errors, + file_common_v1_device_selector, + file_common_v1_sort, + file_buf_validate_validate, + ], + ); + +/** + * MinerStateSnapshot represents the operational state of a mining device + * including performance metrics and component health status + * Can contain either a single point-in-time snapshot or a time series of measurements + * + * @generated from message fleetmanagement.v1.MinerStateSnapshot + */ +export type MinerStateSnapshot = Message<"fleetmanagement.v1.MinerStateSnapshot"> & { + /** + * Unique identifier for the device within the fleet + * Used as the primary reference for the device in API calls + * + * @generated from field: string device_identifier = 1; + */ + deviceIdentifier: string; + + /** + * Human-readable name/identifier of the miner + * + * @generated from field: string name = 2; + */ + name: string; + + /** + * Physical MAC address of the device in XX:XX:XX:XX:XX:XX format + * + * @generated from field: string mac_address = 3; + */ + macAddress: string; + + /** + * Manufacturer-assigned serial number + * + * @generated from field: string serial_number = 4; + */ + serialNumber: string; + + /** + * Power consumption measurements in kilowatts (kW) + * Contains either a single current value or a time series based on request parameters + * + * @generated from field: repeated common.v1.Measurement power_usage = 5; + */ + powerUsage: Measurement[]; + + /** + * Temperature measurements in degrees Celsius + * Contains either a single current value or a time series based on request parameters + * + * @generated from field: repeated common.v1.Measurement temperature = 6; + */ + temperature: Measurement[]; + + /** + * Hashrate measurements in TH/s (terahash per second) + * Contains either a single current value or a time series based on request parameters + * + * @generated from field: repeated common.v1.Measurement hashrate = 7; + */ + hashrate: Measurement[]; + + /** + * Energy efficiency measurements in joules per terahash (j/TH) + * Lower values indicate better efficiency + * Contains either a single current value or a time series based on request parameters + * + * @generated from field: repeated common.v1.Measurement efficiency = 8; + */ + efficiency: Measurement[]; + + /** + * Timestamp when this snapshot was captured + * For time series data, this represents when the most recent data was collected + * + * @generated from field: google.protobuf.Timestamp timestamp = 9; + */ + timestamp?: Timestamp; + + /** + * @generated from field: string ip_address = 10; + */ + ipAddress: string; + + /** + * The full url of the miner including protocol and port (if running on a port other than 80/443) + * + * @generated from field: string url = 11; + */ + url: string; + + /** + * Current operational status of the device + * + * @generated from field: fleetmanagement.v1.DeviceStatus device_status = 12; + */ + deviceStatus: DeviceStatus; + + /** + * Pairing status of the device + * + * @generated from field: fleetmanagement.v1.PairingStatus pairing_status = 13; + */ + pairingStatus: PairingStatus; + + /** + * Device model name (populated for unpaired devices) + * + * @generated from field: string model = 14; + */ + model: string; + + /** + * Manufacturer name (populated for unpaired devices) + * + * @generated from field: string manufacturer = 15; + */ + manufacturer: string; + + /** + * Device capabilities indicating supported features (populated for unpaired devices) + * + * @generated from field: capabilities.v1.MinerCapabilities capabilities = 17; + */ + capabilities?: MinerCapabilities; + + /** + * Temperature status based on current temperature value + * + * @generated from field: telemetry.v1.TemperatureStatus temperature_status = 18; + */ + temperatureStatus: TemperatureStatus; + + /** + * Firmware version installed on the device + * + * @generated from field: string firmware_version = 19; + */ + firmwareVersion: string; + + /** + * Labels of groups this device belongs to + * Empty if the device is not a member of any group + * + * @generated from field: repeated string group_labels = 20; + */ + groupLabels: string[]; + + /** + * Label of the rack this device belongs to (single value since device can only be in one rack) + * Empty if the device is not assigned to a rack + * + * @generated from field: string rack_label = 21; + */ + rackLabel: string; + + /** + * Driver name identifies which plugin handles this device (e.g., "proto", "antminer"). + * + * @generated from field: string driver_name = 22; + */ + driverName: string; + + /** + * Worker name stored on fleet for pool username composition and rename preview. + * + * @generated from field: string worker_name = 23; + */ + workerName: string; + + /** + * Rack slot position formatted using the rack numbering origin (for example "01"). + * Empty if the device is not assigned to a numbered rack slot. + * + * @generated from field: string rack_position = 24; + */ + rackPosition: string; +}; + +/** + * Describes the message fleetmanagement.v1.MinerStateSnapshot. + * Use `create(MinerStateSnapshotSchema)` to create a new message. + */ +export const MinerStateSnapshotSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 0); + +/** + * Request to list miners with their metadata + * Returns paginated list of miners with identification info and status + * Use GetBatchMinerTelemetry to fetch telemetry for specific miners after initial load + * + * @generated from message fleetmanagement.v1.ListMinerStateSnapshotsRequest + */ +export type ListMinerStateSnapshotsRequest = Message<"fleetmanagement.v1.ListMinerStateSnapshotsRequest"> & { + /** + * Maximum number of miners to return in a single response + * Server may return fewer miners than specified + * If not specified, a default of 50 will be used + * Maximum allowed value is 1000 + * + * @generated from field: int32 page_size = 1; + */ + pageSize: number; + + /** + * A pagination cursor returned by a previous call to this endpoint + * Provide this cursor to retrieve the next set of results for the original query + * Leave empty for first request + * + * @generated from field: string cursor = 2; + */ + cursor: string; + + /** + * Filter criteria for the miners to return + * + * @generated from field: fleetmanagement.v1.MinerListFilter filter = 3; + */ + filter?: MinerListFilter; + + /** + * Sort configuration for results ordering + * If not specified or empty, uses default sort by name ASC (alphabetical) + * Currently only the first element is used; multi-column sorting reserved for future + * + * @generated from field: repeated common.v1.SortConfig sort = 4; + */ + sort: SortConfig[]; +}; + +/** + * Describes the message fleetmanagement.v1.ListMinerStateSnapshotsRequest. + * Use `create(ListMinerStateSnapshotsRequestSchema)` to create a new message. + */ +export const ListMinerStateSnapshotsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 1); + +/** + * Filter criteria for miners + * Multiple filters act as AND condition (all must match) + * + * @generated from message fleetmanagement.v1.MinerListFilter + */ +export type MinerListFilter = Message<"fleetmanagement.v1.MinerListFilter"> & { + /** + * Filter by device status (acts as OR condition) + * Returns miners that match any of the specified device statuses + * + * @generated from field: repeated fleetmanagement.v1.DeviceStatus device_status = 3; + */ + deviceStatus: DeviceStatus[]; + + /** + * Filter by component types that have errors (acts as OR condition) + * Returns miners that have errors for any of the specified component types + * Uses ComponentType from errors.v1 package + * + * @generated from field: repeated errors.v1.ComponentType error_component_types = 4; + */ + errorComponentTypes: ComponentType[]; + + /** + * Filter by device models (acts as OR condition) + * Returns miners that match any of the specified model names (e.g., "S21 XP", "M60") + * + * @generated from field: repeated string models = 5; + */ + models: string[]; + + /** + * Filter by pairing statuses (acts as OR condition) + * Returns miners that match any of the specified pairing statuses + * If empty or only contains PAIRING_STATUS_UNSPECIFIED, returns all devices + * Examples: + * [PAIRED] - Only paired devices + * [UNPAIRED] - Only unpaired devices + * [PAIRED, AUTHENTICATION_NEEDED] - Paired devices + devices needing auth + * [AUTHENTICATION_NEEDED, FAILED] - Devices needing attention + * [] or [UNSPECIFIED] - All devices + * + * @generated from field: repeated fleetmanagement.v1.PairingStatus pairing_statuses = 6; + */ + pairingStatuses: PairingStatus[]; + + /** + * Filter by group IDs. Returns miners that belong to ANY of the specified groups. + * When combined with rack_ids, uses AND logic (device must match both filters). + * + * @generated from field: repeated int64 group_ids = 7; + */ + groupIds: bigint[]; + + /** + * Filter by rack IDs. Returns miners that belong to ANY of the specified racks. + * When combined with group_ids, uses AND logic (device must match both filters). + * + * @generated from field: repeated int64 rack_ids = 8; + */ + rackIds: bigint[]; +}; + +/** + * Describes the message fleetmanagement.v1.MinerListFilter. + * Use `create(MinerListFilterSchema)` to create a new message. + */ +export const MinerListFilterSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 2); + +/** + * Response containing a list of miners with their telemetry + * Used by both ListMinerStateSnapshots (one-time query) and StreamFilteredMinerList (streaming updates) + * + * @generated from message fleetmanagement.v1.ListMinerStateSnapshotsResponse + */ +export type ListMinerStateSnapshotsResponse = Message<"fleetmanagement.v1.ListMinerStateSnapshotsResponse"> & { + /** + * List of miners with their telemetry data + * Contains either snapshot or time series data based on the request + * + * @generated from field: repeated fleetmanagement.v1.MinerStateSnapshot miners = 1; + */ + miners: MinerStateSnapshot[]; + + /** + * The pagination cursor to be used in a subsequent request + * If empty, this is the final page of results + * + * @generated from field: string cursor = 2; + */ + cursor: string; + + /** + * Total number of miners available across all pages + * Useful for UI pagination controls + * + * @generated from field: int32 total_miners = 3; + */ + totalMiners: number; + + /** + * Counts of miners in different states + * This includes counts for all miners, not just the ones in the current page + * Status filters do not affect these counts, because they act as OR condition. + * + * @generated from field: telemetry.v1.MinerStateCounts total_state_counts = 4; + */ + totalStateCounts?: MinerStateCounts; + + /** + * List of all device models that exist in the user's fleet + * Useful for dynamically building model filter options in the UI + * This includes all models across all pages, not just the current page (e.g., "S21 XP", "M60") + * + * @generated from field: repeated string models = 5; + */ + models: string[]; +}; + +/** + * Describes the message fleetmanagement.v1.ListMinerStateSnapshotsResponse. + * Use `create(ListMinerStateSnapshotsResponseSchema)` to create a new message. + */ +export const ListMinerStateSnapshotsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 3); + +/** + * @generated from message fleetmanagement.v1.ExportMinerListCsvRequest + */ +export type ExportMinerListCsvRequest = Message<"fleetmanagement.v1.ExportMinerListCsvRequest"> & { + /** + * Filter criteria for the miners to export. + * + * @generated from field: fleetmanagement.v1.MinerListFilter filter = 1; + */ + filter?: MinerListFilter; + + /** + * Preferred temperature unit for the exported temperature column. + * + * @generated from field: fleetmanagement.v1.CsvTemperatureUnit temperature_unit = 2; + */ + temperatureUnit: CsvTemperatureUnit; +}; + +/** + * Describes the message fleetmanagement.v1.ExportMinerListCsvRequest. + * Use `create(ExportMinerListCsvRequestSchema)` to create a new message. + */ +export const ExportMinerListCsvRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 4); + +/** + * @generated from message fleetmanagement.v1.ExportMinerListCsvResponse + */ +export type ExportMinerListCsvResponse = Message<"fleetmanagement.v1.ExportMinerListCsvResponse"> & { + /** + * Chunk of CSV data. Concatenate all chunks to form the complete file. + * + * @generated from field: bytes csv_data = 1; + */ + csvData: Uint8Array; +}; + +/** + * Describes the message fleetmanagement.v1.ExportMinerListCsvResponse. + * Use `create(ExportMinerListCsvResponseSchema)` to create a new message. + */ +export const ExportMinerListCsvResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 5); + +/** + * Request for getting miner state counts without fetching miner data + * + * Reserved for future use + * + * @generated from message fleetmanagement.v1.GetMinerStateCountsRequest + */ +export type GetMinerStateCountsRequest = Message<"fleetmanagement.v1.GetMinerStateCountsRequest"> & {}; + +/** + * Describes the message fleetmanagement.v1.GetMinerStateCountsRequest. + * Use `create(GetMinerStateCountsRequestSchema)` to create a new message. + */ +export const GetMinerStateCountsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 6); + +/** + * Response containing miner state counts + * + * @generated from message fleetmanagement.v1.GetMinerStateCountsResponse + */ +export type GetMinerStateCountsResponse = Message<"fleetmanagement.v1.GetMinerStateCountsResponse"> & { + /** + * Total number of miners matching the filter + * + * @generated from field: int32 total_miners = 1; + */ + totalMiners: number; + + /** + * Counts of miners in different states + * + * @generated from field: telemetry.v1.MinerStateCounts state_counts = 2; + */ + stateCounts?: MinerStateCounts; +}; + +/** + * Describes the message fleetmanagement.v1.GetMinerStateCountsResponse. + * Use `create(GetMinerStateCountsResponseSchema)` to create a new message. + */ +export const GetMinerStateCountsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 7); + +/** + * Request to get the current pool assignments for a specific miner + * + * @generated from message fleetmanagement.v1.GetMinerPoolAssignmentsRequest + */ +export type GetMinerPoolAssignmentsRequest = Message<"fleetmanagement.v1.GetMinerPoolAssignmentsRequest"> & { + /** + * Device identifier of the miner to get pool assignments for + * + * @generated from field: string device_identifier = 1; + */ + deviceIdentifier: string; +}; + +/** + * Describes the message fleetmanagement.v1.GetMinerPoolAssignmentsRequest. + * Use `create(GetMinerPoolAssignmentsRequestSchema)` to create a new message. + */ +export const GetMinerPoolAssignmentsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 8); + +/** + * A single pool assignment from a miner's configuration + * Groups the pool ID (if matched to a fleet pool) with the raw URL and username + * + * @generated from message fleetmanagement.v1.PoolAssignment + */ +export type PoolAssignment = Message<"fleetmanagement.v1.PoolAssignment"> & { + /** + * Fleet pool ID if this pool matches a fleet pool definition + * Absent if the pool doesn't match any fleet pool + * + * @generated from field: optional int64 pool_id = 1; + */ + poolId?: bigint; + + /** + * Raw pool URL as configured on the miner + * + * @generated from field: string url = 2; + */ + url: string; + + /** + * Username as configured on the miner + * + * @generated from field: string username = 3; + */ + username: string; +}; + +/** + * Describes the message fleetmanagement.v1.PoolAssignment. + * Use `create(PoolAssignmentSchema)` to create a new message. + */ +export const PoolAssignmentSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 9); + +/** + * Response containing the miner's current pool assignments + * Pools are returned in priority order: index 0 = default (priority 0), index 1 = backup1 (priority 1), etc. + * If a pool is configured on the miner but doesn't match any fleet pool, the pool_id will be absent + * + * @generated from message fleetmanagement.v1.GetMinerPoolAssignmentsResponse + */ +export type GetMinerPoolAssignmentsResponse = Message<"fleetmanagement.v1.GetMinerPoolAssignmentsResponse"> & { + /** + * Pool assignments in priority order (max 3: default, backup1, backup2) + * Empty if no pools are configured on the miner + * + * @generated from field: repeated fleetmanagement.v1.PoolAssignment pools = 1; + */ + pools: PoolAssignment[]; +}; + +/** + * Describes the message fleetmanagement.v1.GetMinerPoolAssignmentsResponse. + * Use `create(GetMinerPoolAssignmentsResponseSchema)` to create a new message. + */ +export const GetMinerPoolAssignmentsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 10); + +/** + * A single model group result with count + * + * @generated from message fleetmanagement.v1.MinerModelGroup + */ +export type MinerModelGroup = Message<"fleetmanagement.v1.MinerModelGroup"> & { + /** + * @generated from field: string model = 1; + */ + model: string; + + /** + * @generated from field: string manufacturer = 2; + */ + manufacturer: string; + + /** + * @generated from field: int32 count = 3; + */ + count: number; +}; + +/** + * Describes the message fleetmanagement.v1.MinerModelGroup. + * Use `create(MinerModelGroupSchema)` to create a new message. + */ +export const MinerModelGroupSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 11); + +/** + * Request to get miner model groups with counts + * + * @generated from message fleetmanagement.v1.GetMinerModelGroupsRequest + */ +export type GetMinerModelGroupsRequest = Message<"fleetmanagement.v1.GetMinerModelGroupsRequest"> & { + /** + * @generated from field: fleetmanagement.v1.MinerListFilter filter = 1; + */ + filter?: MinerListFilter; +}; + +/** + * Describes the message fleetmanagement.v1.GetMinerModelGroupsRequest. + * Use `create(GetMinerModelGroupsRequestSchema)` to create a new message. + */ +export const GetMinerModelGroupsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 12); + +/** + * Response containing miner model groups with counts + * + * @generated from message fleetmanagement.v1.GetMinerModelGroupsResponse + */ +export type GetMinerModelGroupsResponse = Message<"fleetmanagement.v1.GetMinerModelGroupsResponse"> & { + /** + * @generated from field: repeated fleetmanagement.v1.MinerModelGroup groups = 1; + */ + groups: MinerModelGroup[]; +}; + +/** + * Describes the message fleetmanagement.v1.GetMinerModelGroupsResponse. + * Use `create(GetMinerModelGroupsResponseSchema)` to create a new message. + */ +export const GetMinerModelGroupsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 13); + +/** + * Request to get the current cooling mode for a specific miner + * + * @generated from message fleetmanagement.v1.GetMinerCoolingModeRequest + */ +export type GetMinerCoolingModeRequest = Message<"fleetmanagement.v1.GetMinerCoolingModeRequest"> & { + /** + * Device identifier of the miner to get cooling mode for + * + * @generated from field: string device_identifier = 1; + */ + deviceIdentifier: string; +}; + +/** + * Describes the message fleetmanagement.v1.GetMinerCoolingModeRequest. + * Use `create(GetMinerCoolingModeRequestSchema)` to create a new message. + */ +export const GetMinerCoolingModeRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 14); + +/** + * Response containing the miner's current cooling mode + * + * @generated from message fleetmanagement.v1.GetMinerCoolingModeResponse + */ +export type GetMinerCoolingModeResponse = Message<"fleetmanagement.v1.GetMinerCoolingModeResponse"> & { + /** + * Current cooling mode of the miner + * UNSPECIFIED if the miner doesn't support cooling mode configuration + * An RPC error is returned if the miner could not be queried + * + * @generated from field: common.v1.CoolingMode cooling_mode = 1; + */ + coolingMode: CoolingMode; +}; + +/** + * Describes the message fleetmanagement.v1.GetMinerCoolingModeResponse. + * Use `create(GetMinerCoolingModeResponseSchema)` to create a new message. + */ +export const GetMinerCoolingModeResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 15); + +/** + * Selects devices for fleet management operations. + * Follows the same oneof pattern as minercommand.v1.DeviceSelector. + * + * @generated from message fleetmanagement.v1.DeviceSelector + */ +export type DeviceSelector = Message<"fleetmanagement.v1.DeviceSelector"> & { + /** + * @generated from oneof fleetmanagement.v1.DeviceSelector.selection_type + */ + selectionType: + | { + /** + * Select all paired devices, optionally filtered. + * An empty filter selects all paired devices in the org. + * + * @generated from field: fleetmanagement.v1.MinerListFilter all_devices = 1; + */ + value: MinerListFilter; + case: "allDevices"; + } + | { + /** + * Select specific devices by identifier. + * + * @generated from field: common.v1.DeviceIdentifierList include_devices = 2; + */ + value: DeviceIdentifierList; + case: "includeDevices"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message fleetmanagement.v1.DeviceSelector. + * Use `create(DeviceSelectorSchema)` to create a new message. + */ +export const DeviceSelectorSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 16); + +/** + * Request to delete miners from the fleet. + * + * @generated from message fleetmanagement.v1.DeleteMinersRequest + */ +export type DeleteMinersRequest = Message<"fleetmanagement.v1.DeleteMinersRequest"> & { + /** + * @generated from field: fleetmanagement.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message fleetmanagement.v1.DeleteMinersRequest. + * Use `create(DeleteMinersRequestSchema)` to create a new message. + */ +export const DeleteMinersRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 17); + +/** + * Response from deleting miners + * + * @generated from message fleetmanagement.v1.DeleteMinersResponse + */ +export type DeleteMinersResponse = Message<"fleetmanagement.v1.DeleteMinersResponse"> & { + /** + * Number of devices successfully deleted + * + * @generated from field: int32 deleted_count = 1; + */ + deletedCount: number; +}; + +/** + * Describes the message fleetmanagement.v1.DeleteMinersResponse. + * Use `create(DeleteMinersResponseSchema)` to create a new message. + */ +export const DeleteMinersResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 18); + +/** + * Request to rename miners using a configurable name pattern. + * + * @generated from message fleetmanagement.v1.RenameMinersRequest + */ +export type RenameMinersRequest = Message<"fleetmanagement.v1.RenameMinersRequest"> & { + /** + * Selects which miners to rename (specific IDs or all devices). + * + * @generated from field: fleetmanagement.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; + + /** + * Naming pattern applied to all selected miners. + * + * @generated from field: fleetmanagement.v1.MinerNameConfig name_config = 2; + */ + nameConfig?: MinerNameConfig; + + /** + * Current fleet table sort used to assign counter order during rename. + * If omitted, the backend falls back to the default name ascending order. + * + * @generated from field: repeated common.v1.SortConfig sort = 3; + */ + sort: SortConfig[]; +}; + +/** + * Describes the message fleetmanagement.v1.RenameMinersRequest. + * Use `create(RenameMinersRequestSchema)` to create a new message. + */ +export const RenameMinersRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 19); + +/** + * Response from renaming miners with per-device outcome counts for the batch. + * + * @generated from message fleetmanagement.v1.RenameMinersResponse + */ +export type RenameMinersResponse = Message<"fleetmanagement.v1.RenameMinersResponse"> & { + /** + * @generated from field: int32 renamed_count = 1; + */ + renamedCount: number; + + /** + * @generated from field: int32 unchanged_count = 2; + */ + unchangedCount: number; + + /** + * @generated from field: int32 failed_count = 3; + */ + failedCount: number; +}; + +/** + * Describes the message fleetmanagement.v1.RenameMinersResponse. + * Use `create(RenameMinersResponseSchema)` to create a new message. + */ +export const RenameMinersResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 20); + +/** + * Request to update worker names using a configurable name pattern. + * + * @generated from message fleetmanagement.v1.UpdateWorkerNamesRequest + */ +export type UpdateWorkerNamesRequest = Message<"fleetmanagement.v1.UpdateWorkerNamesRequest"> & { + /** + * Selects which miners to update (specific IDs or all devices). + * + * @generated from field: fleetmanagement.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; + + /** + * Naming pattern applied to the worker name that will be pushed to each selected miner + * and persisted back to Fleet after the device update succeeds. + * + * @generated from field: fleetmanagement.v1.MinerNameConfig name_config = 2; + */ + nameConfig?: MinerNameConfig; + + /** + * Current fleet table sort used to assign counter order during generation. + * If omitted, the backend falls back to the default name ascending order. + * + * @generated from field: repeated common.v1.SortConfig sort = 3; + */ + sort: SortConfig[]; + + /** + * Fleet user's username for authorization. + * + * @generated from field: string user_username = 4; + */ + userUsername: string; + + /** + * Fleet user's password for authorization. + * + * @generated from field: string user_password = 5; + */ + userPassword: string; +}; + +/** + * Describes the message fleetmanagement.v1.UpdateWorkerNamesRequest. + * Use `create(UpdateWorkerNamesRequestSchema)` to create a new message. + */ +export const UpdateWorkerNamesRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 21); + +/** + * Response from updating worker names. + * + * @generated from message fleetmanagement.v1.UpdateWorkerNamesResponse + */ +export type UpdateWorkerNamesResponse = Message<"fleetmanagement.v1.UpdateWorkerNamesResponse"> & { + /** + * @generated from field: int32 updated_count = 1; + */ + updatedCount: number; + + /** + * @generated from field: int32 unchanged_count = 2; + */ + unchangedCount: number; + + /** + * @generated from field: int32 failed_count = 3; + */ + failedCount: number; + + /** + * @generated from field: string batch_identifier = 4; + */ + batchIdentifier: string; +}; + +/** + * Describes the message fleetmanagement.v1.UpdateWorkerNamesResponse. + * Use `create(UpdateWorkerNamesResponseSchema)` to create a new message. + */ +export const UpdateWorkerNamesResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 22); + +/** + * Configures how names are generated for a batch of miners. + * + * @generated from message fleetmanagement.v1.MinerNameConfig + */ +export type MinerNameConfig = Message<"fleetmanagement.v1.MinerNameConfig"> & { + /** + * Ordered list of properties that form the name. At least one is required. + * + * @generated from field: repeated fleetmanagement.v1.NameProperty properties = 1; + */ + properties: NameProperty[]; + + /** + * Separator placed between properties. Must be "-", "_", ".", or "" (no separator). + * + * @generated from field: string separator = 2; + */ + separator: string; +}; + +/** + * Describes the message fleetmanagement.v1.MinerNameConfig. + * Use `create(MinerNameConfigSchema)` to create a new message. + */ +export const MinerNameConfigSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 23); + +/** + * A single named segment in a MinerNameConfig. + * + * @generated from message fleetmanagement.v1.NameProperty + */ +export type NameProperty = Message<"fleetmanagement.v1.NameProperty"> & { + /** + * @generated from oneof fleetmanagement.v1.NameProperty.kind + */ + kind: + | { + /** + * @generated from field: fleetmanagement.v1.StringAndCounterProperty string_and_counter = 1; + */ + value: StringAndCounterProperty; + case: "stringAndCounter"; + } + | { + /** + * @generated from field: fleetmanagement.v1.CounterProperty counter = 2; + */ + value: CounterProperty; + case: "counter"; + } + | { + /** + * @generated from field: fleetmanagement.v1.StringProperty string_value = 3; + */ + value: StringProperty; + case: "stringValue"; + } + | { + /** + * @generated from field: fleetmanagement.v1.FixedValueProperty fixed_value = 4; + */ + value: FixedValueProperty; + case: "fixedValue"; + } + | { + /** + * @generated from field: fleetmanagement.v1.QualifierProperty qualifier = 5; + */ + value: QualifierProperty; + case: "qualifier"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message fleetmanagement.v1.NameProperty. + * Use `create(NamePropertySchema)` to create a new message. + */ +export const NamePropertySchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 24); + +/** + * Generates a segment combining an optional prefix/suffix with a zero-padded counter. + * + * @generated from message fleetmanagement.v1.StringAndCounterProperty + */ +export type StringAndCounterProperty = Message<"fleetmanagement.v1.StringAndCounterProperty"> & { + /** + * Optional prefix prepended to the counter. + * + * @generated from field: string prefix = 1; + */ + prefix: string; + + /** + * Optional suffix appended to the counter. + * + * @generated from field: string suffix = 2; + */ + suffix: string; + + /** + * Starting value for the counter. Must be >= 0. + * + * @generated from field: int32 counter_start = 3; + */ + counterStart: number; + + /** + * Number of digits in the counter (e.g. 3 → 001, 002, 003). Must be 1–6. + * + * @generated from field: int32 counter_scale = 4; + */ + counterScale: number; +}; + +/** + * Describes the message fleetmanagement.v1.StringAndCounterProperty. + * Use `create(StringAndCounterPropertySchema)` to create a new message. + */ +export const StringAndCounterPropertySchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 25); + +/** + * Generates a zero-padded counter segment with no surrounding text. + * + * @generated from message fleetmanagement.v1.CounterProperty + */ +export type CounterProperty = Message<"fleetmanagement.v1.CounterProperty"> & { + /** + * Starting value for the counter. Must be >= 0. + * + * @generated from field: int32 counter_start = 1; + */ + counterStart: number; + + /** + * Number of digits in the counter (e.g. 3 → 001, 002, 003). Must be 1–6. + * + * @generated from field: int32 counter_scale = 2; + */ + counterScale: number; +}; + +/** + * Describes the message fleetmanagement.v1.CounterProperty. + * Use `create(CounterPropertySchema)` to create a new message. + */ +export const CounterPropertySchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 26); + +/** + * Generates a static string segment. + * + * @generated from message fleetmanagement.v1.StringProperty + */ +export type StringProperty = Message<"fleetmanagement.v1.StringProperty"> & { + /** + * The string to insert. Must be non-empty. + * + * @generated from field: string value = 1; + */ + value: string; +}; + +/** + * Describes the message fleetmanagement.v1.StringProperty. + * Use `create(StringPropertySchema)` to create a new message. + */ +export const StringPropertySchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 27); + +/** + * Generates a segment from a device's fixed attribute value. + * + * @generated from message fleetmanagement.v1.FixedValueProperty + */ +export type FixedValueProperty = Message<"fleetmanagement.v1.FixedValueProperty"> & { + /** + * The device attribute to use. + * + * @generated from field: fleetmanagement.v1.FixedValueType type = 1; + */ + type: FixedValueType; + + /** + * Maximum number of characters to take from the attribute value. + * When set, section is required. Must be 1–6. + * + * @generated from field: optional int32 character_count = 2; + */ + characterCount?: number; + + /** + * Which end to take characters from. Required when character_count is set. + * + * @generated from field: optional fleetmanagement.v1.CharacterSection section = 3; + */ + section?: CharacterSection; +}; + +/** + * Describes the message fleetmanagement.v1.FixedValueProperty. + * Use `create(FixedValuePropertySchema)` to create a new message. + */ +export const FixedValuePropertySchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 28); + +/** + * Generates a segment from a device's physical location qualifier. + * + * @generated from message fleetmanagement.v1.QualifierProperty + */ +export type QualifierProperty = Message<"fleetmanagement.v1.QualifierProperty"> & { + /** + * The location qualifier to use. + * + * @generated from field: fleetmanagement.v1.QualifierType type = 1; + */ + type: QualifierType; + + /** + * Optional prefix prepended to the qualifier value. + * + * @generated from field: string prefix = 2; + */ + prefix: string; + + /** + * Optional suffix appended to the qualifier value. + * + * @generated from field: string suffix = 3; + */ + suffix: string; +}; + +/** + * Describes the message fleetmanagement.v1.QualifierProperty. + * Use `create(QualifierPropertySchema)` to create a new message. + */ +export const QualifierPropertySchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 29); + +/** + * @generated from enum fleetmanagement.v1.FleetManagementServiceErrorCode + */ +export enum FleetManagementServiceErrorCode { + /** + * @generated from enum value: FLEET_MANAGEMENT_SERVICE_ERROR_CODE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: FLEET_MANAGEMENT_SERVICE_ERROR_CODE_INVALID_PAGINATION_CURSOR = 1; + */ + INVALID_PAGINATION_CURSOR = 1, +} + +/** + * Describes the enum fleetmanagement.v1.FleetManagementServiceErrorCode. + */ +export const FleetManagementServiceErrorCodeSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_fleetmanagement_v1_fleetmanagement, 0); + +/** + * Status of a miner + * + * @generated from enum fleetmanagement.v1.DeviceStatus + */ +export enum DeviceStatus { + /** + * Status is unknown or not specified + * + * @generated from enum value: DEVICE_STATUS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Miner is online and functioning normally + * + * @generated from enum value: DEVICE_STATUS_ONLINE = 1; + */ + ONLINE = 1, + + /** + * Miner is offline and not responding + * + * @generated from enum value: DEVICE_STATUS_OFFLINE = 2; + */ + OFFLINE = 2, + + /** + * Miner is in maintenance mode + * + * @generated from enum value: DEVICE_STATUS_MAINTENANCE = 3; + */ + MAINTENANCE = 3, + + /** + * Miner is in error state + * + * @generated from enum value: DEVICE_STATUS_ERROR = 4; + */ + ERROR = 4, + + /** + * Miner is inactive, not mining but still connected + * + * @generated from enum value: DEVICE_STATUS_INACTIVE = 5; + */ + INACTIVE = 5, + + /** + * Miner is online but needs a mining pool configured to start mining + * + * @generated from enum value: DEVICE_STATUS_NEEDS_MINING_POOL = 6; + */ + NEEDS_MINING_POOL = 6, + + /** + * Miner is receiving a firmware update (install in progress on device) + * + * @generated from enum value: DEVICE_STATUS_UPDATING = 7; + */ + UPDATING = 7, + + /** + * Miner firmware has been installed but requires a reboot to activate + * + * @generated from enum value: DEVICE_STATUS_REBOOT_REQUIRED = 8; + */ + REBOOT_REQUIRED = 8, +} + +/** + * Describes the enum fleetmanagement.v1.DeviceStatus. + */ +export const DeviceStatusSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_fleetmanagement_v1_fleetmanagement, 1); + +/** + * Pairing status of a device + * + * @generated from enum fleetmanagement.v1.PairingStatus + */ +export enum PairingStatus { + /** + * Any pairing status, returns all devices (used in filters only) + * + * @generated from enum value: PAIRING_STATUS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Only paired devices + * + * @generated from enum value: PAIRING_STATUS_PAIRED = 1; + */ + PAIRED = 1, + + /** + * Only unpaired/discovered devices (no device entry exists) + * + * @generated from enum value: PAIRING_STATUS_UNPAIRED = 2; + */ + UNPAIRED = 2, + + /** + * Devices that require authentication credentials to complete pairing + * + * @generated from enum value: PAIRING_STATUS_AUTHENTICATION_NEEDED = 3; + */ + AUTHENTICATION_NEEDED = 3, + + /** + * Devices that are in the process of being paired + * + * @generated from enum value: PAIRING_STATUS_PENDING = 4; + */ + PENDING = 4, + + /** + * Devices that failed to pair + * + * @generated from enum value: PAIRING_STATUS_FAILED = 5; + */ + FAILED = 5, +} + +/** + * Describes the enum fleetmanagement.v1.PairingStatus. + */ +export const PairingStatusSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_fleetmanagement_v1_fleetmanagement, 2); + +/** + * @generated from enum fleetmanagement.v1.CsvTemperatureUnit + */ +export enum CsvTemperatureUnit { + /** + * @generated from enum value: CSV_TEMPERATURE_UNIT_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: CSV_TEMPERATURE_UNIT_CELSIUS = 1; + */ + CELSIUS = 1, + + /** + * @generated from enum value: CSV_TEMPERATURE_UNIT_FAHRENHEIT = 2; + */ + FAHRENHEIT = 2, +} + +/** + * Describes the enum fleetmanagement.v1.CsvTemperatureUnit. + */ +export const CsvTemperatureUnitSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_fleetmanagement_v1_fleetmanagement, 3); + +/** + * Device attribute used as a name segment. + * + * @generated from enum fleetmanagement.v1.FixedValueType + */ +export enum FixedValueType { + /** + * @generated from enum value: FIXED_VALUE_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: FIXED_VALUE_TYPE_MAC_ADDRESS = 1; + */ + MAC_ADDRESS = 1, + + /** + * @generated from enum value: FIXED_VALUE_TYPE_SERIAL_NUMBER = 2; + */ + SERIAL_NUMBER = 2, + + /** + * Resolves to the worker name stored on fleet for the miner. + * + * @generated from enum value: FIXED_VALUE_TYPE_WORKER_NAME = 3; + */ + WORKER_NAME = 3, + + /** + * @generated from enum value: FIXED_VALUE_TYPE_MODEL = 4; + */ + MODEL = 4, + + /** + * @generated from enum value: FIXED_VALUE_TYPE_MANUFACTURER = 5; + */ + MANUFACTURER = 5, + + /** + * Reserved — not yet implemented. + * + * @generated from enum value: FIXED_VALUE_TYPE_LOCATION = 6; + */ + LOCATION = 6, + + /** + * Resolves to the miner's current fleet name (custom name or manufacturer + model fallback). + * + * @generated from enum value: FIXED_VALUE_TYPE_MINER_NAME = 7; + */ + MINER_NAME = 7, +} + +/** + * Describes the enum fleetmanagement.v1.FixedValueType. + */ +export const FixedValueTypeSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_fleetmanagement_v1_fleetmanagement, 4); + +/** + * Selects which end of a string to take characters from. + * + * @generated from enum fleetmanagement.v1.CharacterSection + */ +export enum CharacterSection { + /** + * @generated from enum value: CHARACTER_SECTION_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: CHARACTER_SECTION_FIRST = 1; + */ + FIRST = 1, + + /** + * @generated from enum value: CHARACTER_SECTION_LAST = 2; + */ + LAST = 2, +} + +/** + * Describes the enum fleetmanagement.v1.CharacterSection. + */ +export const CharacterSectionSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_fleetmanagement_v1_fleetmanagement, 5); + +/** + * Physical location qualifier used as a name segment. + * + * @generated from enum fleetmanagement.v1.QualifierType + */ +export enum QualifierType { + /** + * @generated from enum value: QUALIFIER_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Reserved — not yet implemented. + * + * @generated from enum value: QUALIFIER_TYPE_BUILDING = 1; + */ + BUILDING = 1, + + /** + * Resolves to the assigned rack label. + * + * @generated from enum value: QUALIFIER_TYPE_RACK = 2; + */ + RACK = 2, + + /** + * Resolves to the assigned rack slot number. + * + * @generated from enum value: QUALIFIER_TYPE_RACK_POSITION = 3; + */ + RACK_POSITION = 3, +} + +/** + * Describes the enum fleetmanagement.v1.QualifierType. + */ +export const QualifierTypeSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_fleetmanagement_v1_fleetmanagement, 6); + +/** + * Service for managing fleet-wide settings and configurations + * + * @generated from service fleetmanagement.v1.FleetManagementService + */ +export const FleetManagementService: GenService<{ + /** + * List all devices in the fleet (paired and/or unpaired) optionally with their telemetry data + * Returns a paginated list of devices with their operational status and metrics + * Use the pairing_status filter to control whether to return paired, unpaired, or both + * + * @generated from rpc fleetmanagement.v1.FleetManagementService.ListMinerStateSnapshots + */ + listMinerStateSnapshots: { + methodKind: "unary"; + input: typeof ListMinerStateSnapshotsRequestSchema; + output: typeof ListMinerStateSnapshotsResponseSchema; + }; + /** + * Export the paired miner list as a CSV snapshot using the provided filter. + * Rows are always emitted in default name-ascending order for cross-page consistency. + * The server paginates internally and streams CSV data in chunks. + * + * @generated from rpc fleetmanagement.v1.FleetManagementService.ExportMinerListCsv + */ + exportMinerListCsv: { + methodKind: "server_streaming"; + input: typeof ExportMinerListCsvRequestSchema; + output: typeof ExportMinerListCsvResponseSchema; + }; + /** + * Get counts of miners in different states without fetching miner data + * More efficient than ListMinerStateSnapshots when only counts are needed + * + * @generated from rpc fleetmanagement.v1.FleetManagementService.GetMinerStateCounts + */ + getMinerStateCounts: { + methodKind: "unary"; + input: typeof GetMinerStateCountsRequestSchema; + output: typeof GetMinerStateCountsResponseSchema; + }; + /** + * Get the current pool assignments for a specific miner + * Returns the fleet pool IDs that match the miner's currently configured pools + * Used to pre-populate the pool selection UI when editing a miner's pools + * + * @generated from rpc fleetmanagement.v1.FleetManagementService.GetMinerPoolAssignments + */ + getMinerPoolAssignments: { + methodKind: "unary"; + input: typeof GetMinerPoolAssignmentsRequestSchema; + output: typeof GetMinerPoolAssignmentsResponseSchema; + }; + /** + * Get the current cooling mode for a specific miner + * Returns the cooling mode configuration from the miner + * Used to pre-populate the cooling mode selection UI when editing a miner's settings + * + * @generated from rpc fleetmanagement.v1.FleetManagementService.GetMinerCoolingMode + */ + getMinerCoolingMode: { + methodKind: "unary"; + input: typeof GetMinerCoolingModeRequestSchema; + output: typeof GetMinerCoolingModeResponseSchema; + }; + /** + * Delete miners from the fleet by soft-deleting their database records. + * Immediately removes devices from the fleet and telemetry collection. + * Attempts best-effort ClearAuthKey on Proto rigs in the background. + * + * @generated from rpc fleetmanagement.v1.FleetManagementService.DeleteMiners + */ + deleteMiners: { + methodKind: "unary"; + input: typeof DeleteMinersRequestSchema; + output: typeof DeleteMinersResponseSchema; + }; + /** + * Get miner model groups with counts, optionally filtered by the current fleet filter + * Used for bulk password update to show accurate model groups across the full fleet + * + * @generated from rpc fleetmanagement.v1.FleetManagementService.GetMinerModelGroups + */ + getMinerModelGroups: { + methodKind: "unary"; + input: typeof GetMinerModelGroupsRequestSchema; + output: typeof GetMinerModelGroupsResponseSchema; + }; + /** + * Rename miners by applying a name config to all selected devices. + * Supports both single-miner and bulk rename via DeviceSelector. + * Persists all names atomically in a single transaction. + * + * @generated from rpc fleetmanagement.v1.FleetManagementService.RenameMiners + */ + renameMiners: { + methodKind: "unary"; + input: typeof RenameMinersRequestSchema; + output: typeof RenameMinersResponseSchema; + }; + /** + * Update worker names by applying a name config to all selected devices. + * Reapplies the miners' current pool settings first, then persists worker names + * only for miners whose pool updates succeed so Fleet stays aligned with device state. + * + * @generated from rpc fleetmanagement.v1.FleetManagementService.UpdateWorkerNames + */ + updateWorkerNames: { + methodKind: "unary"; + input: typeof UpdateWorkerNamesRequestSchema; + output: typeof UpdateWorkerNamesResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_fleetmanagement_v1_fleetmanagement, 0); diff --git a/client/src/protoFleet/api/generated/fleetperformance/v1/fleetperformance_pb.ts b/client/src/protoFleet/api/generated/fleetperformance/v1/fleetperformance_pb.ts new file mode 100644 index 000000000..1a855eec1 --- /dev/null +++ b/client/src/protoFleet/api/generated/fleetperformance/v1/fleetperformance_pb.ts @@ -0,0 +1,622 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file fleetperformance/v1/fleetperformance.proto (package fleetperformance.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import type { Measurement } from "../../common/v1/measurement_pb"; +import { file_common_v1_measurement } from "../../common/v1/measurement_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file fleetperformance/v1/fleetperformance.proto. + */ +export const file_fleetperformance_v1_fleetperformance: GenFile = + /*@__PURE__*/ + fileDesc( + "CipmbGVldHBlcmZvcm1hbmNlL3YxL2ZsZWV0cGVyZm9ybWFuY2UucHJvdG8SE2ZsZWV0cGVyZm9ybWFuY2UudjEiHAoaR2V0RmxlZXRQZXJmb3JtYW5jZVJlcXVlc3QiXwobR2V0RmxlZXRQZXJmb3JtYW5jZVJlc3BvbnNlEkAKEWZsZWV0X3BlcmZvcm1hbmNlGAEgASgLMiUuZmxlZXRwZXJmb3JtYW5jZS52MS5GbGVldFBlcmZvcm1hbmNlIrcCChBGbGVldFBlcmZvcm1hbmNlEjQKCG92ZXJ2aWV3GAEgASgLMiIuZmxlZXRwZXJmb3JtYW5jZS52MS5GbGVldE92ZXJ2aWV3EjkKCGhhc2hyYXRlGAIgASgLMicuZmxlZXRwZXJmb3JtYW5jZS52MS5QZXJmb3JtYW5jZU1ldHJpY3MSOwoKZWZmaWNpZW5jeRgDIAEoCzInLmZsZWV0cGVyZm9ybWFuY2UudjEuUGVyZm9ybWFuY2VNZXRyaWNzEjwKC3Bvd2VyX3VzYWdlGAQgASgLMicuZmxlZXRwZXJmb3JtYW5jZS52MS5QZXJmb3JtYW5jZU1ldHJpY3MSNwoGdXB0aW1lGAUgASgLMicuZmxlZXRwZXJmb3JtYW5jZS52MS5QZXJmb3JtYW5jZU1ldHJpY3MiUQoNRmxlZXRPdmVydmlldxIPCgdvZmZsaW5lGAEgASgFEhAKCGluYWN0aXZlGAIgASgFEg4KBmFjdGl2ZRgDIAEoBRINCgV0b3RhbBgEIAEoBSJkChJQZXJmb3JtYW5jZU1ldHJpY3MSKAoFc3RhdHMYASADKAsyGS5mbGVldHBlcmZvcm1hbmNlLnYxLlN0YXQSJAoEZGF0YRgCIAMoCzIWLmNvbW1vbi52MS5NZWFzdXJlbWVudCKDAQoEU3RhdBINCgVsYWJlbBgBIAEoCRIZCg9mb3JtYXR0ZWRfdmFsdWUYAiABKAlIABIzChFtZWFzdXJlbWVudF92YWx1ZRgDIAEoCzIWLmNvbW1vbi52MS5NZWFzdXJlbWVudEgAEhMKC2Rlc2NyaXB0aW9uGAQgASgJQgcKBXZhbHVlIkAKGlN0cmVhbUZsZWV0T3ZlcnZpZXdSZXF1ZXN0EiIKGmhlYXJ0YmVhdF9pbnRlcnZhbF9zZWNvbmRzGAEgASgFIskBChtTdHJlYW1GbGVldE92ZXJ2aWV3UmVzcG9uc2USLQoJdGltZXN0YW1wGAEgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBI8CghvdmVydmlldxgCIAEoCzIoLmZsZWV0cGVyZm9ybWFuY2UudjEuRmxlZXRPdmVydmlld1VwZGF0ZUgAEjMKCWhlYXJ0YmVhdBgDIAEoCzIeLmZsZWV0cGVyZm9ybWFuY2UudjEuSGVhcnRiZWF0SABCCAoGdXBkYXRlIksKE0ZsZWV0T3ZlcnZpZXdVcGRhdGUSNAoIb3ZlcnZpZXcYASABKAsyIi5mbGVldHBlcmZvcm1hbmNlLnYxLkZsZWV0T3ZlcnZpZXcivAEKH1N0cmVhbVBlcmZvcm1hbmNlTWV0cmljc1JlcXVlc3QSQAoMbWV0cmljX3R5cGVzGAEgAygOMiouZmxlZXRwZXJmb3JtYW5jZS52MS5QZXJmb3JtYW5jZU1ldHJpY1R5cGUSFQoNaW5jbHVkZV9zdGF0cxgCIAEoCBIcChRpbmNsdWRlX21lYXN1cmVtZW50cxgDIAEoCBIiChpoZWFydGJlYXRfaW50ZXJ2YWxfc2Vjb25kcxgEIAEoBSLQAQogU3RyZWFtUGVyZm9ybWFuY2VNZXRyaWNzUmVzcG9uc2USLQoJdGltZXN0YW1wGAEgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBI+CgZtZXRyaWMYAiABKAsyLC5mbGVldHBlcmZvcm1hbmNlLnYxLlBlcmZvcm1hbmNlTWV0cmljVXBkYXRlSAASMwoJaGVhcnRiZWF0GAMgASgLMh4uZmxlZXRwZXJmb3JtYW5jZS52MS5IZWFydGJlYXRIAEIICgZ1cGRhdGUi2wEKF1BlcmZvcm1hbmNlTWV0cmljVXBkYXRlEj8KC21ldHJpY190eXBlGAEgASgOMiouZmxlZXRwZXJmb3JtYW5jZS52MS5QZXJmb3JtYW5jZU1ldHJpY1R5cGUSMQoFc3RhdHMYAiABKAsyIC5mbGVldHBlcmZvcm1hbmNlLnYxLlN0YXRzVXBkYXRlSAASPQoLbWVhc3VyZW1lbnQYAyABKAsyJi5mbGVldHBlcmZvcm1hbmNlLnYxLk1lYXN1cmVtZW50VXBkYXRlSABCDQoLdXBkYXRlX3R5cGUiNwoLU3RhdHNVcGRhdGUSKAoFc3RhdHMYASADKAsyGS5mbGVldHBlcmZvcm1hbmNlLnYxLlN0YXQiQAoRTWVhc3VyZW1lbnRVcGRhdGUSKwoLbWVhc3VyZW1lbnQYASABKAsyFi5jb21tb24udjEuTWVhc3VyZW1lbnQiCwoJSGVhcnRiZWF0KtsBChVQZXJmb3JtYW5jZU1ldHJpY1R5cGUSJwojUEVSRk9STUFOQ0VfTUVUUklDX1RZUEVfVU5TUEVDSUZJRUQQABIkCiBQRVJGT1JNQU5DRV9NRVRSSUNfVFlQRV9IQVNIUkFURRABEiYKIlBFUkZPUk1BTkNFX01FVFJJQ19UWVBFX0VGRklDSUVOQ1kQAhInCiNQRVJGT1JNQU5DRV9NRVRSSUNfVFlQRV9QT1dFUl9VU0FHRRADEiIKHlBFUkZPUk1BTkNFX01FVFJJQ19UWVBFX1VQVElNRRAEMpsDChdGbGVldFBlcmZvcm1hbmNlU2VydmljZRJ4ChNHZXRGbGVldFBlcmZvcm1hbmNlEi8uZmxlZXRwZXJmb3JtYW5jZS52MS5HZXRGbGVldFBlcmZvcm1hbmNlUmVxdWVzdBowLmZsZWV0cGVyZm9ybWFuY2UudjEuR2V0RmxlZXRQZXJmb3JtYW5jZVJlc3BvbnNlEnoKE1N0cmVhbUZsZWV0T3ZlcnZpZXcSLy5mbGVldHBlcmZvcm1hbmNlLnYxLlN0cmVhbUZsZWV0T3ZlcnZpZXdSZXF1ZXN0GjAuZmxlZXRwZXJmb3JtYW5jZS52MS5TdHJlYW1GbGVldE92ZXJ2aWV3UmVzcG9uc2UwARKJAQoYU3RyZWFtUGVyZm9ybWFuY2VNZXRyaWNzEjQuZmxlZXRwZXJmb3JtYW5jZS52MS5TdHJlYW1QZXJmb3JtYW5jZU1ldHJpY3NSZXF1ZXN0GjUuZmxlZXRwZXJmb3JtYW5jZS52MS5TdHJlYW1QZXJmb3JtYW5jZU1ldHJpY3NSZXNwb25zZTABQvgBChdjb20uZmxlZXRwZXJmb3JtYW5jZS52MUIVRmxlZXRwZXJmb3JtYW5jZVByb3RvUAFaWWdpdGh1Yi5jb20vYmxvY2svcHJvdG8tZmxlZXQvc2VydmVyL2dlbmVyYXRlZC9ncnBjL2ZsZWV0cGVyZm9ybWFuY2UvdjE7ZmxlZXRwZXJmb3JtYW5jZXYxogIDRlhYqgITRmxlZXRwZXJmb3JtYW5jZS5WMcoCE0ZsZWV0cGVyZm9ybWFuY2VcVjHiAh9GbGVldHBlcmZvcm1hbmNlXFYxXEdQQk1ldGFkYXRh6gIURmxlZXRwZXJmb3JtYW5jZTo6VjFiBnByb3RvMw", + [file_google_protobuf_timestamp, file_common_v1_measurement], + ); + +/** + * Request to retrieve fleet performance metrics + * + * Reserved for future use - currently returns default time range + * + * @generated from message fleetperformance.v1.GetFleetPerformanceRequest + */ +export type GetFleetPerformanceRequest = Message<"fleetperformance.v1.GetFleetPerformanceRequest"> & {}; + +/** + * Describes the message fleetperformance.v1.GetFleetPerformanceRequest. + * Use `create(GetFleetPerformanceRequestSchema)` to create a new message. + */ +export const GetFleetPerformanceRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetperformance_v1_fleetperformance, 0); + +/** + * Response containing comprehensive fleet performance metrics + * + * @generated from message fleetperformance.v1.GetFleetPerformanceResponse + */ +export type GetFleetPerformanceResponse = Message<"fleetperformance.v1.GetFleetPerformanceResponse"> & { + /** + * Fleet performance data containing all metrics + * + * @generated from field: fleetperformance.v1.FleetPerformance fleet_performance = 1; + */ + fleetPerformance?: FleetPerformance; +}; + +/** + * Describes the message fleetperformance.v1.GetFleetPerformanceResponse. + * Use `create(GetFleetPerformanceResponseSchema)` to create a new message. + */ +export const GetFleetPerformanceResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetperformance_v1_fleetperformance, 1); + +/** + * Comprehensive fleet performance metrics + * + * @generated from message fleetperformance.v1.FleetPerformance + */ +export type FleetPerformance = Message<"fleetperformance.v1.FleetPerformance"> & { + /** + * Overview statistics showing fleet status distribution + * + * @generated from field: fleetperformance.v1.FleetOverview overview = 1; + */ + overview?: FleetOverview; + + /** + * Hashrate performance metrics and trends + * + * @generated from field: fleetperformance.v1.PerformanceMetrics hashrate = 2; + */ + hashrate?: PerformanceMetrics; + + /** + * Energy efficiency metrics and trends + * + * @generated from field: fleetperformance.v1.PerformanceMetrics efficiency = 3; + */ + efficiency?: PerformanceMetrics; + + /** + * Power usage metrics and trends + * + * @generated from field: fleetperformance.v1.PerformanceMetrics power_usage = 4; + */ + powerUsage?: PerformanceMetrics; + + /** + * Uptime metrics and trends + * + * @generated from field: fleetperformance.v1.PerformanceMetrics uptime = 5; + */ + uptime?: PerformanceMetrics; +}; + +/** + * Describes the message fleetperformance.v1.FleetPerformance. + * Use `create(FleetPerformanceSchema)` to create a new message. + */ +export const FleetPerformanceSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetperformance_v1_fleetperformance, 2); + +/** + * Fleet overview showing device status distribution + * + * @generated from message fleetperformance.v1.FleetOverview + */ +export type FleetOverview = Message<"fleetperformance.v1.FleetOverview"> & { + /** + * Number of devices that are offline + * + * @generated from field: int32 offline = 1; + */ + offline: number; + + /** + * Number of devices that are inactive + * + * @generated from field: int32 inactive = 2; + */ + inactive: number; + + /** + * Number of devices that are actively mining + * + * @generated from field: int32 active = 3; + */ + active: number; + + /** + * Total number of devices in the fleet + * + * @generated from field: int32 total = 4; + */ + total: number; +}; + +/** + * Describes the message fleetperformance.v1.FleetOverview. + * Use `create(FleetOverviewSchema)` to create a new message. + */ +export const FleetOverviewSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetperformance_v1_fleetperformance, 3); + +/** + * Performance metrics containing statistics and time series data + * + * @generated from message fleetperformance.v1.PerformanceMetrics + */ +export type PerformanceMetrics = Message<"fleetperformance.v1.PerformanceMetrics"> & { + /** + * Statistical summary of the performance metric + * + * @generated from field: repeated fleetperformance.v1.Stat stats = 1; + */ + stats: Stat[]; + + /** + * Time series data points for the performance metric + * + * @generated from field: repeated common.v1.Measurement data = 2; + */ + data: Measurement[]; +}; + +/** + * Describes the message fleetperformance.v1.PerformanceMetrics. + * Use `create(PerformanceMetricsSchema)` to create a new message. + */ +export const PerformanceMetricsSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetperformance_v1_fleetperformance, 4); + +/** + * Statistical information about a performance metric + * + * @generated from message fleetperformance.v1.Stat + */ +export type Stat = Message<"fleetperformance.v1.Stat"> & { + /** + * Brief descriptive label for the statistic (e.g., "Average", "Peak", "Minimum") + * + * @generated from field: string label = 1; + */ + label: string; + + /** + * Value of the statistic - either formatted string or structured measurement + * + * @generated from oneof fleetperformance.v1.Stat.value + */ + value: + | { + /** + * Formatted value as a string (e.g., "125.5 TH/s", "98.2%") + * + * @generated from field: string formatted_value = 2; + */ + value: string; + case: "formattedValue"; + } + | { + /** + * Structured measurement with timestamp, value, and unit + * + * @generated from field: common.v1.Measurement measurement_value = 3; + */ + value: Measurement; + case: "measurementValue"; + } + | { case: undefined; value?: undefined }; + + /** + * Detailed description of what this statistic represents + * + * @generated from field: string description = 4; + */ + description: string; +}; + +/** + * Describes the message fleetperformance.v1.Stat. + * Use `create(StatSchema)` to create a new message. + */ +export const StatSchema: GenMessage = /*@__PURE__*/ messageDesc(file_fleetperformance_v1_fleetperformance, 5); + +/** + * Request to stream fleet overview updates + * + * @generated from message fleetperformance.v1.StreamFleetOverviewRequest + */ +export type StreamFleetOverviewRequest = Message<"fleetperformance.v1.StreamFleetOverviewRequest"> & { + /** + * Optional heartbeat interval in seconds (0 means no heartbeats) + * Heartbeats help detect connection issues and keep streams alive + * + * @generated from field: int32 heartbeat_interval_seconds = 1; + */ + heartbeatIntervalSeconds: number; +}; + +/** + * Describes the message fleetperformance.v1.StreamFleetOverviewRequest. + * Use `create(StreamFleetOverviewRequestSchema)` to create a new message. + */ +export const StreamFleetOverviewRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetperformance_v1_fleetperformance, 6); + +/** + * Response containing fleet overview updates + * + * @generated from message fleetperformance.v1.StreamFleetOverviewResponse + */ +export type StreamFleetOverviewResponse = Message<"fleetperformance.v1.StreamFleetOverviewResponse"> & { + /** + * Timestamp when this update was generated + * + * @generated from field: google.protobuf.Timestamp timestamp = 1; + */ + timestamp?: Timestamp; + + /** + * Type of update + * + * @generated from oneof fleetperformance.v1.StreamFleetOverviewResponse.update + */ + update: + | { + /** + * Fleet overview status update + * + * @generated from field: fleetperformance.v1.FleetOverviewUpdate overview = 2; + */ + value: FleetOverviewUpdate; + case: "overview"; + } + | { + /** + * Heartbeat to keep connection alive (no data) + * + * @generated from field: fleetperformance.v1.Heartbeat heartbeat = 3; + */ + value: Heartbeat; + case: "heartbeat"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message fleetperformance.v1.StreamFleetOverviewResponse. + * Use `create(StreamFleetOverviewResponseSchema)` to create a new message. + */ +export const StreamFleetOverviewResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetperformance_v1_fleetperformance, 7); + +/** + * Fleet overview update containing the latest status distribution + * + * @generated from message fleetperformance.v1.FleetOverviewUpdate + */ +export type FleetOverviewUpdate = Message<"fleetperformance.v1.FleetOverviewUpdate"> & { + /** + * Updated fleet overview with current device status counts + * + * @generated from field: fleetperformance.v1.FleetOverview overview = 1; + */ + overview?: FleetOverview; +}; + +/** + * Describes the message fleetperformance.v1.FleetOverviewUpdate. + * Use `create(FleetOverviewUpdateSchema)` to create a new message. + */ +export const FleetOverviewUpdateSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetperformance_v1_fleetperformance, 8); + +/** + * Request to stream performance metrics updates + * + * @generated from message fleetperformance.v1.StreamPerformanceMetricsRequest + */ +export type StreamPerformanceMetricsRequest = Message<"fleetperformance.v1.StreamPerformanceMetricsRequest"> & { + /** + * Types of performance metrics to stream + * If empty, streams all performance metric types + * + * @generated from field: repeated fleetperformance.v1.PerformanceMetricType metric_types = 1; + */ + metricTypes: PerformanceMetricType[]; + + /** + * Whether to include statistical summaries in updates + * If true, includes updated Stat arrays when statistics change + * + * @generated from field: bool include_stats = 2; + */ + includeStats: boolean; + + /** + * Whether to include individual measurement data points + * If true, includes new Measurement data as it becomes available + * + * @generated from field: bool include_measurements = 3; + */ + includeMeasurements: boolean; + + /** + * Optional heartbeat interval in seconds (0 means no heartbeats) + * Heartbeats help detect connection issues and keep streams alive + * + * @generated from field: int32 heartbeat_interval_seconds = 4; + */ + heartbeatIntervalSeconds: number; +}; + +/** + * Describes the message fleetperformance.v1.StreamPerformanceMetricsRequest. + * Use `create(StreamPerformanceMetricsRequestSchema)` to create a new message. + */ +export const StreamPerformanceMetricsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetperformance_v1_fleetperformance, 9); + +/** + * Response containing performance metrics updates + * + * @generated from message fleetperformance.v1.StreamPerformanceMetricsResponse + */ +export type StreamPerformanceMetricsResponse = Message<"fleetperformance.v1.StreamPerformanceMetricsResponse"> & { + /** + * Timestamp when this update was generated + * + * @generated from field: google.protobuf.Timestamp timestamp = 1; + */ + timestamp?: Timestamp; + + /** + * Type of update + * + * @generated from oneof fleetperformance.v1.StreamPerformanceMetricsResponse.update + */ + update: + | { + /** + * Performance metric update + * + * @generated from field: fleetperformance.v1.PerformanceMetricUpdate metric = 2; + */ + value: PerformanceMetricUpdate; + case: "metric"; + } + | { + /** + * Heartbeat to keep connection alive (no data) + * + * @generated from field: fleetperformance.v1.Heartbeat heartbeat = 3; + */ + value: Heartbeat; + case: "heartbeat"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message fleetperformance.v1.StreamPerformanceMetricsResponse. + * Use `create(StreamPerformanceMetricsResponseSchema)` to create a new message. + */ +export const StreamPerformanceMetricsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetperformance_v1_fleetperformance, 10); + +/** + * Performance metric update for a specific metric type + * + * @generated from message fleetperformance.v1.PerformanceMetricUpdate + */ +export type PerformanceMetricUpdate = Message<"fleetperformance.v1.PerformanceMetricUpdate"> & { + /** + * The type of performance metric being updated + * + * @generated from field: fleetperformance.v1.PerformanceMetricType metric_type = 1; + */ + metricType: PerformanceMetricType; + + /** + * Type of metric update + * + * @generated from oneof fleetperformance.v1.PerformanceMetricUpdate.update_type + */ + updateType: + | { + /** + * Updated statistical summary + * + * @generated from field: fleetperformance.v1.StatsUpdate stats = 2; + */ + value: StatsUpdate; + case: "stats"; + } + | { + /** + * New measurement data point + * + * @generated from field: fleetperformance.v1.MeasurementUpdate measurement = 3; + */ + value: MeasurementUpdate; + case: "measurement"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message fleetperformance.v1.PerformanceMetricUpdate. + * Use `create(PerformanceMetricUpdateSchema)` to create a new message. + */ +export const PerformanceMetricUpdateSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetperformance_v1_fleetperformance, 11); + +/** + * Updated statistical summary for a performance metric + * + * @generated from message fleetperformance.v1.StatsUpdate + */ +export type StatsUpdate = Message<"fleetperformance.v1.StatsUpdate"> & { + /** + * Updated statistics for the performance metric + * + * @generated from field: repeated fleetperformance.v1.Stat stats = 1; + */ + stats: Stat[]; +}; + +/** + * Describes the message fleetperformance.v1.StatsUpdate. + * Use `create(StatsUpdateSchema)` to create a new message. + */ +export const StatsUpdateSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetperformance_v1_fleetperformance, 12); + +/** + * New measurement data point for a performance metric + * + * @generated from message fleetperformance.v1.MeasurementUpdate + */ +export type MeasurementUpdate = Message<"fleetperformance.v1.MeasurementUpdate"> & { + /** + * New measurement data point + * + * @generated from field: common.v1.Measurement measurement = 1; + */ + measurement?: Measurement; +}; + +/** + * Describes the message fleetperformance.v1.MeasurementUpdate. + * Use `create(MeasurementUpdateSchema)` to create a new message. + */ +export const MeasurementUpdateSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetperformance_v1_fleetperformance, 13); + +/** + * Heartbeat message to keep streaming connections alive + * + * Empty message for heartbeat + * + * @generated from message fleetperformance.v1.Heartbeat + */ +export type Heartbeat = Message<"fleetperformance.v1.Heartbeat"> & {}; + +/** + * Describes the message fleetperformance.v1.Heartbeat. + * Use `create(HeartbeatSchema)` to create a new message. + */ +export const HeartbeatSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetperformance_v1_fleetperformance, 14); + +/** + * Types of performance metrics available for streaming + * + * @generated from enum fleetperformance.v1.PerformanceMetricType + */ +export enum PerformanceMetricType { + /** + * Metric type not specified + * + * @generated from enum value: PERFORMANCE_METRIC_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Hashrate performance metrics + * + * @generated from enum value: PERFORMANCE_METRIC_TYPE_HASHRATE = 1; + */ + HASHRATE = 1, + + /** + * Energy efficiency metrics + * + * @generated from enum value: PERFORMANCE_METRIC_TYPE_EFFICIENCY = 2; + */ + EFFICIENCY = 2, + + /** + * Power usage metrics + * + * @generated from enum value: PERFORMANCE_METRIC_TYPE_POWER_USAGE = 3; + */ + POWER_USAGE = 3, + + /** + * Uptime metrics + * + * @generated from enum value: PERFORMANCE_METRIC_TYPE_UPTIME = 4; + */ + UPTIME = 4, +} + +/** + * Describes the enum fleetperformance.v1.PerformanceMetricType. + */ +export const PerformanceMetricTypeSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_fleetperformance_v1_fleetperformance, 0); + +/** + * FleetPerformanceService provides fleet-wide performance metrics and analytics + * + * @generated from service fleetperformance.v1.FleetPerformanceService + */ +export const FleetPerformanceService: GenService<{ + /** + * GetFleetPerformance retrieves comprehensive performance metrics for the entire fleet + * Returns aggregated statistics and time series data for various performance indicators + * + * @generated from rpc fleetperformance.v1.FleetPerformanceService.GetFleetPerformance + */ + getFleetPerformance: { + methodKind: "unary"; + input: typeof GetFleetPerformanceRequestSchema; + output: typeof GetFleetPerformanceResponseSchema; + }; + /** + * StreamFleetOverview provides real-time updates for fleet overview statistics + * Returns a continuous stream of fleet status distribution changes + * + * @generated from rpc fleetperformance.v1.FleetPerformanceService.StreamFleetOverview + */ + streamFleetOverview: { + methodKind: "server_streaming"; + input: typeof StreamFleetOverviewRequestSchema; + output: typeof StreamFleetOverviewResponseSchema; + }; + /** + * StreamPerformanceMetrics provides real-time updates for specific performance metrics + * Returns a continuous stream of metric updates for subscribed performance types + * + * @generated from rpc fleetperformance.v1.FleetPerformanceService.StreamPerformanceMetrics + */ + streamPerformanceMetrics: { + methodKind: "server_streaming"; + input: typeof StreamPerformanceMetricsRequestSchema; + output: typeof StreamPerformanceMetricsResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_fleetperformance_v1_fleetperformance, 0); diff --git a/client/src/protoFleet/api/generated/foremanimport/v1/foremanimport_pb.ts b/client/src/protoFleet/api/generated/foremanimport/v1/foremanimport_pb.ts new file mode 100644 index 000000000..e5729685b --- /dev/null +++ b/client/src/protoFleet/api/generated/foremanimport/v1/foremanimport_pb.ts @@ -0,0 +1,222 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file foremanimport/v1/foremanimport.proto (package foremanimport.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file foremanimport/v1/foremanimport.proto. + */ +export const file_foremanimport_v1_foremanimport: GenFile = + /*@__PURE__*/ + fileDesc( + "CiRmb3JlbWFuaW1wb3J0L3YxL2ZvcmVtYW5pbXBvcnQucHJvdG8SEGZvcmVtYW5pbXBvcnQudjEiOAoSRm9yZW1hbkNyZWRlbnRpYWxzEg8KB2FwaV9rZXkYASABKAkSEQoJY2xpZW50X2lkGAIgASgJIlUKGEltcG9ydEZyb21Gb3JlbWFuUmVxdWVzdBI5CgtjcmVkZW50aWFscxgBIAEoCzIkLmZvcmVtYW5pbXBvcnQudjEuRm9yZW1hbkNyZWRlbnRpYWxzIlQKDEZvcmVtYW5NaW5lchISCgppcF9hZGRyZXNzGAEgASgJEhMKC21hY19hZGRyZXNzGAIgASgJEgwKBG5hbWUYAyABKAkSDQoFbW9kZWwYBCABKAkiSwoZSW1wb3J0RnJvbUZvcmVtYW5SZXNwb25zZRIuCgZtaW5lcnMYASADKAsyHi5mb3JlbWFuaW1wb3J0LnYxLkZvcmVtYW5NaW5lciK4AQoVQ29tcGxldGVJbXBvcnRSZXF1ZXN0EjkKC2NyZWRlbnRpYWxzGAEgASgLMiQuZm9yZW1hbmltcG9ydC52MS5Gb3JlbWFuQ3JlZGVudGlhbHMSFAoMaW1wb3J0X3Bvb2xzGAIgASgIEhUKDWltcG9ydF9ncm91cHMYAyABKAgSFAoMaW1wb3J0X3JhY2tzGAQgASgIEiEKGXBhaXJlZF9kZXZpY2VfaWRlbnRpZmllcnMYBSADKAkiqwEKFkNvbXBsZXRlSW1wb3J0UmVzcG9uc2USFQoNcG9vbHNfY3JlYXRlZBgBIAEoBRIWCg5ncm91cHNfY3JlYXRlZBgCIAEoBRIVCg1yYWNrc19jcmVhdGVkGAMgASgFEhgKEGRldmljZXNfYXNzaWduZWQYBCABKAUSGAoQd29ya2VyX25hbWVzX3NldBgFIAEoBRIXCg9taW5lcl9uYW1lc19zZXQYBiABKAUy6QEKFEZvcmVtYW5JbXBvcnRTZXJ2aWNlEmwKEUltcG9ydEZyb21Gb3JlbWFuEiouZm9yZW1hbmltcG9ydC52MS5JbXBvcnRGcm9tRm9yZW1hblJlcXVlc3QaKy5mb3JlbWFuaW1wb3J0LnYxLkltcG9ydEZyb21Gb3JlbWFuUmVzcG9uc2USYwoOQ29tcGxldGVJbXBvcnQSJy5mb3JlbWFuaW1wb3J0LnYxLkNvbXBsZXRlSW1wb3J0UmVxdWVzdBooLmZvcmVtYW5pbXBvcnQudjEuQ29tcGxldGVJbXBvcnRSZXNwb25zZULgAQoUY29tLmZvcmVtYW5pbXBvcnQudjFCEkZvcmVtYW5pbXBvcnRQcm90b1ABWlNnaXRodWIuY29tL2Jsb2NrL3Byb3RvLWZsZWV0L3NlcnZlci9nZW5lcmF0ZWQvZ3JwYy9mb3JlbWFuaW1wb3J0L3YxO2ZvcmVtYW5pbXBvcnR2MaICA0ZYWKoCEEZvcmVtYW5pbXBvcnQuVjHKAhBGb3JlbWFuaW1wb3J0XFYx4gIcRm9yZW1hbmltcG9ydFxWMVxHUEJNZXRhZGF0YeoCEUZvcmVtYW5pbXBvcnQ6OlYxYgZwcm90bzM", + ); + +/** + * @generated from message foremanimport.v1.ForemanCredentials + */ +export type ForemanCredentials = Message<"foremanimport.v1.ForemanCredentials"> & { + /** + * @generated from field: string api_key = 1; + */ + apiKey: string; + + /** + * @generated from field: string client_id = 2; + */ + clientId: string; +}; + +/** + * Describes the message foremanimport.v1.ForemanCredentials. + * Use `create(ForemanCredentialsSchema)` to create a new message. + */ +export const ForemanCredentialsSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_foremanimport_v1_foremanimport, 0); + +/** + * @generated from message foremanimport.v1.ImportFromForemanRequest + */ +export type ImportFromForemanRequest = Message<"foremanimport.v1.ImportFromForemanRequest"> & { + /** + * @generated from field: foremanimport.v1.ForemanCredentials credentials = 1; + */ + credentials?: ForemanCredentials; +}; + +/** + * Describes the message foremanimport.v1.ImportFromForemanRequest. + * Use `create(ImportFromForemanRequestSchema)` to create a new message. + */ +export const ImportFromForemanRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_foremanimport_v1_foremanimport, 1); + +/** + * @generated from message foremanimport.v1.ForemanMiner + */ +export type ForemanMiner = Message<"foremanimport.v1.ForemanMiner"> & { + /** + * @generated from field: string ip_address = 1; + */ + ipAddress: string; + + /** + * @generated from field: string mac_address = 2; + */ + macAddress: string; + + /** + * @generated from field: string name = 3; + */ + name: string; + + /** + * @generated from field: string model = 4; + */ + model: string; +}; + +/** + * Describes the message foremanimport.v1.ForemanMiner. + * Use `create(ForemanMinerSchema)` to create a new message. + */ +export const ForemanMinerSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_foremanimport_v1_foremanimport, 2); + +/** + * @generated from message foremanimport.v1.ImportFromForemanResponse + */ +export type ImportFromForemanResponse = Message<"foremanimport.v1.ImportFromForemanResponse"> & { + /** + * @generated from field: repeated foremanimport.v1.ForemanMiner miners = 1; + */ + miners: ForemanMiner[]; +}; + +/** + * Describes the message foremanimport.v1.ImportFromForemanResponse. + * Use `create(ImportFromForemanResponseSchema)` to create a new message. + */ +export const ImportFromForemanResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_foremanimport_v1_foremanimport, 3); + +/** + * @generated from message foremanimport.v1.CompleteImportRequest + */ +export type CompleteImportRequest = Message<"foremanimport.v1.CompleteImportRequest"> & { + /** + * @generated from field: foremanimport.v1.ForemanCredentials credentials = 1; + */ + credentials?: ForemanCredentials; + + /** + * Toggle which entity types to import. + * + * @generated from field: bool import_pools = 2; + */ + importPools: boolean; + + /** + * @generated from field: bool import_groups = 3; + */ + importGroups: boolean; + + /** + * @generated from field: bool import_racks = 4; + */ + importRacks: boolean; + + /** + * Only process Foreman miners whose IPs were paired as these device identifiers. + * If empty, all Foreman miners are processed (backward compatible). + * + * @generated from field: repeated string paired_device_identifiers = 5; + */ + pairedDeviceIdentifiers: string[]; +}; + +/** + * Describes the message foremanimport.v1.CompleteImportRequest. + * Use `create(CompleteImportRequestSchema)` to create a new message. + */ +export const CompleteImportRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_foremanimport_v1_foremanimport, 4); + +/** + * @generated from message foremanimport.v1.CompleteImportResponse + */ +export type CompleteImportResponse = Message<"foremanimport.v1.CompleteImportResponse"> & { + /** + * @generated from field: int32 pools_created = 1; + */ + poolsCreated: number; + + /** + * @generated from field: int32 groups_created = 2; + */ + groupsCreated: number; + + /** + * @generated from field: int32 racks_created = 3; + */ + racksCreated: number; + + /** + * @generated from field: int32 devices_assigned = 4; + */ + devicesAssigned: number; + + /** + * @generated from field: int32 worker_names_set = 5; + */ + workerNamesSet: number; + + /** + * @generated from field: int32 miner_names_set = 6; + */ + minerNamesSet: number; +}; + +/** + * Describes the message foremanimport.v1.CompleteImportResponse. + * Use `create(CompleteImportResponseSchema)` to create a new message. + */ +export const CompleteImportResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_foremanimport_v1_foremanimport, 5); + +/** + * @generated from service foremanimport.v1.ForemanImportService + */ +export const ForemanImportService: GenService<{ + /** + * Validates Foreman credentials and returns miner IPs for discovery+pairing. + * Does NOT create pools/groups/racks — call CompleteImport after pairing. + * + * @generated from rpc foremanimport.v1.ForemanImportService.ImportFromForeman + */ + importFromForeman: { + methodKind: "unary"; + input: typeof ImportFromForemanRequestSchema; + output: typeof ImportFromForemanResponseSchema; + }; + /** + * Creates pools/groups/racks and assigns paired devices to their collections. + * Call this after miners from ImportFromForeman have been discovered and paired. + * + * @generated from rpc foremanimport.v1.ForemanImportService.CompleteImport + */ + completeImport: { + methodKind: "unary"; + input: typeof CompleteImportRequestSchema; + output: typeof CompleteImportResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_foremanimport_v1_foremanimport, 0); diff --git a/client/src/protoFleet/api/generated/minercommand/v1/command_pb.ts b/client/src/protoFleet/api/generated/minercommand/v1/command_pb.ts new file mode 100644 index 000000000..160aef0b8 --- /dev/null +++ b/client/src/protoFleet/api/generated/minercommand/v1/command_pb.ts @@ -0,0 +1,1172 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file minercommand/v1/command.proto (package minercommand.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import type { DeviceStatus, PairingStatus } from "../../fleetmanagement/v1/fleetmanagement_pb"; +import { file_fleetmanagement_v1_fleetmanagement } from "../../fleetmanagement/v1/fleetmanagement_pb"; +import type { DeviceIdentifierList } from "../../common/v1/device_selector_pb"; +import { file_common_v1_device_selector } from "../../common/v1/device_selector_pb"; +import type { CoolingMode } from "../../common/v1/cooling_pb"; +import { file_common_v1_cooling } from "../../common/v1/cooling_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file minercommand/v1/command.proto. + */ +export const file_minercommand_v1_command: GenFile = + /*@__PURE__*/ + fileDesc( + "Ch1taW5lcmNvbW1hbmQvdjEvY29tbWFuZC5wcm90bxIPbWluZXJjb21tYW5kLnYxIqkBCgxEZXZpY2VGaWx0ZXISNwoNZGV2aWNlX3N0YXR1cxgBIAMoDjIgLmZsZWV0bWFuYWdlbWVudC52MS5EZXZpY2VTdGF0dXMSOQoOcGFpcmluZ19zdGF0dXMYAiADKA4yIS5mbGVldG1hbmFnZW1lbnQudjEuUGFpcmluZ1N0YXR1cxIOCgZtb2RlbHMYAyADKAkSFQoNbWFudWZhY3R1cmVycxgEIAMoCSKUAQoORGV2aWNlU2VsZWN0b3ISNAoLYWxsX2RldmljZXMYASABKAsyHS5taW5lcmNvbW1hbmQudjEuRGV2aWNlRmlsdGVySAASOgoPaW5jbHVkZV9kZXZpY2VzGAIgASgLMh8uY29tbW9uLnYxLkRldmljZUlkZW50aWZpZXJMaXN0SABCEAoOc2VsZWN0aW9uX3R5cGUiSQoNUmVib290UmVxdWVzdBI4Cg9kZXZpY2Vfc2VsZWN0b3IYASABKAsyHy5taW5lcmNvbW1hbmQudjEuRGV2aWNlU2VsZWN0b3IiKgoOUmVib290UmVzcG9uc2USGAoQYmF0Y2hfaWRlbnRpZmllchgBIAEoCSJNChFTdG9wTWluaW5nUmVxdWVzdBI4Cg9kZXZpY2Vfc2VsZWN0b3IYASABKAsyHy5taW5lcmNvbW1hbmQudjEuRGV2aWNlU2VsZWN0b3IiLgoSU3RvcE1pbmluZ1Jlc3BvbnNlEhgKEGJhdGNoX2lkZW50aWZpZXIYASABKAkiTgoSU3RhcnRNaW5pbmdSZXF1ZXN0EjgKD2RldmljZV9zZWxlY3RvchgBIAEoCzIfLm1pbmVyY29tbWFuZC52MS5EZXZpY2VTZWxlY3RvciIvChNTdGFydE1pbmluZ1Jlc3BvbnNlEhgKEGJhdGNoX2lkZW50aWZpZXIYASABKAkidwoVU2V0Q29vbGluZ01vZGVSZXF1ZXN0EjgKD2RldmljZV9zZWxlY3RvchgBIAEoCzIfLm1pbmVyY29tbWFuZC52MS5EZXZpY2VTZWxlY3RvchIkCgRtb2RlGAIgASgOMhYuY29tbW9uLnYxLkNvb2xpbmdNb2RlIjIKFlNldENvb2xpbmdNb2RlUmVzcG9uc2USGAoQYmF0Y2hfaWRlbnRpZmllchgBIAEoCSKNAQoVU2V0UG93ZXJUYXJnZXRSZXF1ZXN0EjgKD2RldmljZV9zZWxlY3RvchgBIAEoCzIfLm1pbmVyY29tbWFuZC52MS5EZXZpY2VTZWxlY3RvchI6ChBwZXJmb3JtYW5jZV9tb2RlGAIgASgOMiAubWluZXJjb21tYW5kLnYxLlBlcmZvcm1hbmNlTW9kZSIyChZTZXRQb3dlclRhcmdldFJlc3BvbnNlEhgKEGJhdGNoX2lkZW50aWZpZXIYASABKAkiZAoOUG9vbFNsb3RDb25maWcSEQoHcG9vbF9pZBgBIAEoA0gAEjAKCHJhd19wb29sGAIgASgLMhwubWluZXJjb21tYW5kLnYxLlJhd1Bvb2xJbmZvSABCDQoLcG9vbF9zb3VyY2UiUAoLUmF3UG9vbEluZm8SCwoDdXJsGAEgASgJEhAKCHVzZXJuYW1lGAIgASgJEhUKCHBhc3N3b3JkGAMgASgJSACIAQFCCwoJX3Bhc3N3b3JkItcCChhVcGRhdGVNaW5pbmdQb29sc1JlcXVlc3QSOAoPZGV2aWNlX3NlbGVjdG9yGAEgASgLMh8ubWluZXJjb21tYW5kLnYxLkRldmljZVNlbGVjdG9yEjUKDGRlZmF1bHRfcG9vbBgCIAEoCzIfLm1pbmVyY29tbWFuZC52MS5Qb29sU2xvdENvbmZpZxI7Cg1iYWNrdXBfMV9wb29sGAMgASgLMh8ubWluZXJjb21tYW5kLnYxLlBvb2xTbG90Q29uZmlnSACIAQESOwoNYmFja3VwXzJfcG9vbBgEIAEoCzIfLm1pbmVyY29tbWFuZC52MS5Qb29sU2xvdENvbmZpZ0gBiAEBEhUKDXVzZXJfdXNlcm5hbWUYBSABKAkSFQoNdXNlcl9wYXNzd29yZBgGIAEoCUIQCg5fYmFja3VwXzFfcG9vbEIQCg5fYmFja3VwXzJfcG9vbCI1ChlVcGRhdGVNaW5pbmdQb29sc1Jlc3BvbnNlEhgKEGJhdGNoX2lkZW50aWZpZXIYASABKAkiTwoTRG93bmxvYWRMb2dzUmVxdWVzdBI4Cg9kZXZpY2Vfc2VsZWN0b3IYASABKAsyHy5taW5lcmNvbW1hbmQudjEuRGV2aWNlU2VsZWN0b3IiMAoURG93bmxvYWRMb2dzUmVzcG9uc2USGAoQYmF0Y2hfaWRlbnRpZmllchgBIAEoCSJLCg9CbGlua0xFRFJlcXVlc3QSOAoPZGV2aWNlX3NlbGVjdG9yGAEgASgLMh8ubWluZXJjb21tYW5kLnYxLkRldmljZVNlbGVjdG9yIiwKEEJsaW5rTEVEUmVzcG9uc2USGAoQYmF0Y2hfaWRlbnRpZmllchgBIAEoCSJrChVGaXJtd2FyZVVwZGF0ZVJlcXVlc3QSOAoPZGV2aWNlX3NlbGVjdG9yGAEgASgLMh8ubWluZXJjb21tYW5kLnYxLkRldmljZVNlbGVjdG9yEhgKEGZpcm13YXJlX2ZpbGVfaWQYAiABKAkiMgoWRmlybXdhcmVVcGRhdGVSZXNwb25zZRIYChBiYXRjaF9pZGVudGlmaWVyGAEgASgJIkkKDVVucGFpclJlcXVlc3QSOAoPZGV2aWNlX3NlbGVjdG9yGAEgASgLMh8ubWluZXJjb21tYW5kLnYxLkRldmljZVNlbGVjdG9yIioKDlVucGFpclJlc3BvbnNlEhgKEGJhdGNoX2lkZW50aWZpZXIYASABKAkitAEKGlVwZGF0ZU1pbmVyUGFzc3dvcmRSZXF1ZXN0EjgKD2RldmljZV9zZWxlY3RvchgBIAEoCzIfLm1pbmVyY29tbWFuZC52MS5EZXZpY2VTZWxlY3RvchIUCgxuZXdfcGFzc3dvcmQYAiABKAkSGAoQY3VycmVudF9wYXNzd29yZBgDIAEoCRIVCg11c2VyX3VzZXJuYW1lGAQgASgJEhUKDXVzZXJfcGFzc3dvcmQYBSABKAkiNwobVXBkYXRlTWluZXJQYXNzd29yZFJlc3BvbnNlEhgKEGJhdGNoX2lkZW50aWZpZXIYASABKAkiPAogU3RyZWFtQ29tbWFuZEJhdGNoVXBkYXRlc1JlcXVlc3QSGAoQYmF0Y2hfaWRlbnRpZmllchgBIAEoCSKvAQohU3RyZWFtQ29tbWFuZEJhdGNoVXBkYXRlc1Jlc3BvbnNlEi0KCXRpbWVzdGFtcBgBIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASIAoYY29tbWFuZF9iYXRjaF9pZGVudGlmaWVyGAIgASgJEjkKBnN0YXR1cxgDIAEoCzIpLm1pbmVyY29tbWFuZC52MS5Db21tYW5kQmF0Y2hVcGRhdGVTdGF0dXMimAEKHUNvbW1hbmRCYXRjaFVwZGF0ZURldmljZUNvdW50Eg0KBXRvdGFsGAEgASgDEg8KB3N1Y2Nlc3MYAiABKAMSDwoHZmFpbHVyZRgDIAEoAxIiChpzdWNjZXNzX2RldmljZV9pZGVudGlmaWVycxgEIAMoCRIiChpmYWlsdXJlX2RldmljZV9pZGVudGlmaWVycxgFIAMoCSK8AwoYQ29tbWFuZEJhdGNoVXBkYXRlU3RhdHVzEmsKG2NvbW1hbmRfYmF0Y2hfdXBkYXRlX3N0YXR1cxgBIAEoDjJGLm1pbmVyY29tbWFuZC52MS5Db21tYW5kQmF0Y2hVcGRhdGVTdGF0dXMuQ29tbWFuZEJhdGNoVXBkYXRlU3RhdHVzVHlwZRJSChpjb21tYW5kX2JhdGNoX2RldmljZV9jb3VudBgCIAEoCzIuLm1pbmVyY29tbWFuZC52MS5Db21tYW5kQmF0Y2hVcGRhdGVEZXZpY2VDb3VudCLeAQocQ29tbWFuZEJhdGNoVXBkYXRlU3RhdHVzVHlwZRIwCixDT01NQU5EX0JBVENIX1VQREFURV9TVEFUVVNfVFlQRV9VTlNQRUNJRklFRBAAEiwKKENPTU1BTkRfQkFUQ0hfVVBEQVRFX1NUQVRVU19UWVBFX1BFTkRJTkcQARIvCitDT01NQU5EX0JBVENIX1VQREFURV9TVEFUVVNfVFlQRV9QUk9DRVNTSU5HEAISLQopQ09NTUFORF9CQVRDSF9VUERBVEVfU1RBVFVTX1RZUEVfRklOSVNIRUQQAyI7Ch9HZXRDb21tYW5kQmF0Y2hMb2dCdW5kbGVSZXF1ZXN0EhgKEGJhdGNoX2lkZW50aWZpZXIYASABKAkiSAogR2V0Q29tbWFuZEJhdGNoTG9nQnVuZGxlUmVzcG9uc2USEgoKY2h1bmtfZGF0YRgBIAEoDBIQCghmaWxlbmFtZRgCIAEoCSKPAQofQ2hlY2tDb21tYW5kQ2FwYWJpbGl0aWVzUmVxdWVzdBIyCgxjb21tYW5kX3R5cGUYASABKA4yHC5taW5lcmNvbW1hbmQudjEuQ29tbWFuZFR5cGUSOAoPZGV2aWNlX3NlbGVjdG9yGAIgASgLMh8ubWluZXJjb21tYW5kLnYxLkRldmljZVNlbGVjdG9yIk8KFVVuc3VwcG9ydGVkTWluZXJHcm91cBIYChBmaXJtd2FyZV92ZXJzaW9uGAEgASgJEg0KBW1vZGVsGAIgASgJEg0KBWNvdW50GAMgASgFIoQCCiBDaGVja0NvbW1hbmRDYXBhYmlsaXRpZXNSZXNwb25zZRIXCg9zdXBwb3J0ZWRfY291bnQYASABKAUSGQoRdW5zdXBwb3J0ZWRfY291bnQYAiABKAUSEwoLdG90YWxfY291bnQYAyABKAUSFQoNYWxsX3N1cHBvcnRlZBgEIAEoCBIWCg5ub25lX3N1cHBvcnRlZBgFIAEoCBJCChJ1bnN1cHBvcnRlZF9ncm91cHMYBiADKAsyJi5taW5lcmNvbW1hbmQudjEuVW5zdXBwb3J0ZWRNaW5lckdyb3VwEiQKHHN1cHBvcnRlZF9kZXZpY2VfaWRlbnRpZmllcnMYByADKAkqewoPUGVyZm9ybWFuY2VNb2RlEiAKHFBFUkZPUk1BTkNFX01PREVfVU5TUEVDSUZJRUQQABIlCiFQRVJGT1JNQU5DRV9NT0RFX01BWElNVU1fSEFTSFJBVEUQARIfChtQRVJGT1JNQU5DRV9NT0RFX0VGRklDSUVOQ1kQAirzAgoLQ29tbWFuZFR5cGUSHAoYQ09NTUFORF9UWVBFX1VOU1BFQ0lGSUVEEAASFwoTQ09NTUFORF9UWVBFX1JFQk9PVBABEh0KGUNPTU1BTkRfVFlQRV9TVEFSVF9NSU5JTkcQAhIcChhDT01NQU5EX1RZUEVfU1RPUF9NSU5JTkcQAxIaChZDT01NQU5EX1RZUEVfQkxJTktfTEVEEAQSIQodQ09NTUFORF9UWVBFX1NFVF9DT09MSU5HX01PREUQBRIkCiBDT01NQU5EX1RZUEVfVVBEQVRFX01JTklOR19QT09MUxAGEh4KGkNPTU1BTkRfVFlQRV9ET1dOTE9BRF9MT0dTEAcSIAocQ09NTUFORF9UWVBFX0ZJUk1XQVJFX1VQREFURRAIEiEKHUNPTU1BTkRfVFlQRV9TRVRfUE9XRVJfVEFSR0VUEAkSJgoiQ09NTUFORF9UWVBFX1VQREFURV9NSU5FUl9QQVNTV09SRBAKMpoLChNNaW5lckNvbW1hbmRTZXJ2aWNlEkkKBlJlYm9vdBIeLm1pbmVyY29tbWFuZC52MS5SZWJvb3RSZXF1ZXN0Gh8ubWluZXJjb21tYW5kLnYxLlJlYm9vdFJlc3BvbnNlElUKClN0b3BNaW5pbmcSIi5taW5lcmNvbW1hbmQudjEuU3RvcE1pbmluZ1JlcXVlc3QaIy5taW5lcmNvbW1hbmQudjEuU3RvcE1pbmluZ1Jlc3BvbnNlElgKC1N0YXJ0TWluaW5nEiMubWluZXJjb21tYW5kLnYxLlN0YXJ0TWluaW5nUmVxdWVzdBokLm1pbmVyY29tbWFuZC52MS5TdGFydE1pbmluZ1Jlc3BvbnNlEmEKDlNldENvb2xpbmdNb2RlEiYubWluZXJjb21tYW5kLnYxLlNldENvb2xpbmdNb2RlUmVxdWVzdBonLm1pbmVyY29tbWFuZC52MS5TZXRDb29saW5nTW9kZVJlc3BvbnNlEmEKDlNldFBvd2VyVGFyZ2V0EiYubWluZXJjb21tYW5kLnYxLlNldFBvd2VyVGFyZ2V0UmVxdWVzdBonLm1pbmVyY29tbWFuZC52MS5TZXRQb3dlclRhcmdldFJlc3BvbnNlEmoKEVVwZGF0ZU1pbmluZ1Bvb2xzEikubWluZXJjb21tYW5kLnYxLlVwZGF0ZU1pbmluZ1Bvb2xzUmVxdWVzdBoqLm1pbmVyY29tbWFuZC52MS5VcGRhdGVNaW5pbmdQb29sc1Jlc3BvbnNlElsKDERvd25sb2FkTG9ncxIkLm1pbmVyY29tbWFuZC52MS5Eb3dubG9hZExvZ3NSZXF1ZXN0GiUubWluZXJjb21tYW5kLnYxLkRvd25sb2FkTG9nc1Jlc3BvbnNlEk8KCEJsaW5rTEVEEiAubWluZXJjb21tYW5kLnYxLkJsaW5rTEVEUmVxdWVzdBohLm1pbmVyY29tbWFuZC52MS5CbGlua0xFRFJlc3BvbnNlEoQBChlTdHJlYW1Db21tYW5kQmF0Y2hVcGRhdGVzEjEubWluZXJjb21tYW5kLnYxLlN0cmVhbUNvbW1hbmRCYXRjaFVwZGF0ZXNSZXF1ZXN0GjIubWluZXJjb21tYW5kLnYxLlN0cmVhbUNvbW1hbmRCYXRjaFVwZGF0ZXNSZXNwb25zZTABEn8KGEdldENvbW1hbmRCYXRjaExvZ0J1bmRsZRIwLm1pbmVyY29tbWFuZC52MS5HZXRDb21tYW5kQmF0Y2hMb2dCdW5kbGVSZXF1ZXN0GjEubWluZXJjb21tYW5kLnYxLkdldENvbW1hbmRCYXRjaExvZ0J1bmRsZVJlc3BvbnNlEmEKDkZpcm13YXJlVXBkYXRlEiYubWluZXJjb21tYW5kLnYxLkZpcm13YXJlVXBkYXRlUmVxdWVzdBonLm1pbmVyY29tbWFuZC52MS5GaXJtd2FyZVVwZGF0ZVJlc3BvbnNlEkkKBlVucGFpchIeLm1pbmVyY29tbWFuZC52MS5VbnBhaXJSZXF1ZXN0Gh8ubWluZXJjb21tYW5kLnYxLlVucGFpclJlc3BvbnNlEnAKE1VwZGF0ZU1pbmVyUGFzc3dvcmQSKy5taW5lcmNvbW1hbmQudjEuVXBkYXRlTWluZXJQYXNzd29yZFJlcXVlc3QaLC5taW5lcmNvbW1hbmQudjEuVXBkYXRlTWluZXJQYXNzd29yZFJlc3BvbnNlEn8KGENoZWNrQ29tbWFuZENhcGFiaWxpdGllcxIwLm1pbmVyY29tbWFuZC52MS5DaGVja0NvbW1hbmRDYXBhYmlsaXRpZXNSZXF1ZXN0GjEubWluZXJjb21tYW5kLnYxLkNoZWNrQ29tbWFuZENhcGFiaWxpdGllc1Jlc3BvbnNlQtMBChNjb20ubWluZXJjb21tYW5kLnYxQgxDb21tYW5kUHJvdG9QAVpRZ2l0aHViLmNvbS9ibG9jay9wcm90by1mbGVldC9zZXJ2ZXIvZ2VuZXJhdGVkL2dycGMvbWluZXJjb21tYW5kL3YxO21pbmVyY29tbWFuZHYxogIDTVhYqgIPTWluZXJjb21tYW5kLlYxygIPTWluZXJjb21tYW5kXFYx4gIbTWluZXJjb21tYW5kXFYxXEdQQk1ldGFkYXRh6gIQTWluZXJjb21tYW5kOjpWMWIGcHJvdG8z", + [ + file_google_protobuf_timestamp, + file_fleetmanagement_v1_fleetmanagement, + file_common_v1_device_selector, + file_common_v1_cooling, + ], + ); + +/** + * @generated from message minercommand.v1.DeviceFilter + */ +export type DeviceFilter = Message<"minercommand.v1.DeviceFilter"> & { + /** + * @generated from field: repeated fleetmanagement.v1.DeviceStatus device_status = 1; + */ + deviceStatus: DeviceStatus[]; + + /** + * @generated from field: repeated fleetmanagement.v1.PairingStatus pairing_status = 2; + */ + pairingStatus: PairingStatus[]; + + /** + * @generated from field: repeated string models = 3; + */ + models: string[]; + + /** + * @generated from field: repeated string manufacturers = 4; + */ + manufacturers: string[]; +}; + +/** + * Describes the message minercommand.v1.DeviceFilter. + * Use `create(DeviceFilterSchema)` to create a new message. + */ +export const DeviceFilterSchema: GenMessage = /*@__PURE__*/ messageDesc(file_minercommand_v1_command, 0); + +/** + * @generated from message minercommand.v1.DeviceSelector + */ +export type DeviceSelector = Message<"minercommand.v1.DeviceSelector"> & { + /** + * @generated from oneof minercommand.v1.DeviceSelector.selection_type + */ + selectionType: + | { + /** + * @generated from field: minercommand.v1.DeviceFilter all_devices = 1; + */ + value: DeviceFilter; + case: "allDevices"; + } + | { + /** + * @generated from field: common.v1.DeviceIdentifierList include_devices = 2; + */ + value: DeviceIdentifierList; + case: "includeDevices"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message minercommand.v1.DeviceSelector. + * Use `create(DeviceSelectorSchema)` to create a new message. + */ +export const DeviceSelectorSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 1); + +/** + * @generated from message minercommand.v1.RebootRequest + */ +export type RebootRequest = Message<"minercommand.v1.RebootRequest"> & { + /** + * @generated from field: minercommand.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message minercommand.v1.RebootRequest. + * Use `create(RebootRequestSchema)` to create a new message. + */ +export const RebootRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 2); + +/** + * @generated from message minercommand.v1.RebootResponse + */ +export type RebootResponse = Message<"minercommand.v1.RebootResponse"> & { + /** + * @generated from field: string batch_identifier = 1; + */ + batchIdentifier: string; +}; + +/** + * Describes the message minercommand.v1.RebootResponse. + * Use `create(RebootResponseSchema)` to create a new message. + */ +export const RebootResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 3); + +/** + * Request to stop mining on specific miners + * + * @generated from message minercommand.v1.StopMiningRequest + */ +export type StopMiningRequest = Message<"minercommand.v1.StopMiningRequest"> & { + /** + * @generated from field: minercommand.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message minercommand.v1.StopMiningRequest. + * Use `create(StopMiningRequestSchema)` to create a new message. + */ +export const StopMiningRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 4); + +/** + * Response from stop mining request + * + * @generated from message minercommand.v1.StopMiningResponse + */ +export type StopMiningResponse = Message<"minercommand.v1.StopMiningResponse"> & { + /** + * @generated from field: string batch_identifier = 1; + */ + batchIdentifier: string; +}; + +/** + * Describes the message minercommand.v1.StopMiningResponse. + * Use `create(StopMiningResponseSchema)` to create a new message. + */ +export const StopMiningResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 5); + +/** + * Request to start mining on specific miners + * + * @generated from message minercommand.v1.StartMiningRequest + */ +export type StartMiningRequest = Message<"minercommand.v1.StartMiningRequest"> & { + /** + * @generated from field: minercommand.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message minercommand.v1.StartMiningRequest. + * Use `create(StartMiningRequestSchema)` to create a new message. + */ +export const StartMiningRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 6); + +/** + * Response from start mining request + * + * @generated from message minercommand.v1.StartMiningResponse + */ +export type StartMiningResponse = Message<"minercommand.v1.StartMiningResponse"> & { + /** + * @generated from field: string batch_identifier = 1; + */ + batchIdentifier: string; +}; + +/** + * Describes the message minercommand.v1.StartMiningResponse. + * Use `create(StartMiningResponseSchema)` to create a new message. + */ +export const StartMiningResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 7); + +/** + * @generated from message minercommand.v1.SetCoolingModeRequest + */ +export type SetCoolingModeRequest = Message<"minercommand.v1.SetCoolingModeRequest"> & { + /** + * @generated from field: minercommand.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; + + /** + * @generated from field: common.v1.CoolingMode mode = 2; + */ + mode: CoolingMode; +}; + +/** + * Describes the message minercommand.v1.SetCoolingModeRequest. + * Use `create(SetCoolingModeRequestSchema)` to create a new message. + */ +export const SetCoolingModeRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 8); + +/** + * @generated from message minercommand.v1.SetCoolingModeResponse + */ +export type SetCoolingModeResponse = Message<"minercommand.v1.SetCoolingModeResponse"> & { + /** + * @generated from field: string batch_identifier = 1; + */ + batchIdentifier: string; +}; + +/** + * Describes the message minercommand.v1.SetCoolingModeResponse. + * Use `create(SetCoolingModeResponseSchema)` to create a new message. + */ +export const SetCoolingModeResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 9); + +/** + * @generated from message minercommand.v1.SetPowerTargetRequest + */ +export type SetPowerTargetRequest = Message<"minercommand.v1.SetPowerTargetRequest"> & { + /** + * @generated from field: minercommand.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; + + /** + * @generated from field: minercommand.v1.PerformanceMode performance_mode = 2; + */ + performanceMode: PerformanceMode; +}; + +/** + * Describes the message minercommand.v1.SetPowerTargetRequest. + * Use `create(SetPowerTargetRequestSchema)` to create a new message. + */ +export const SetPowerTargetRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 10); + +/** + * @generated from message minercommand.v1.SetPowerTargetResponse + */ +export type SetPowerTargetResponse = Message<"minercommand.v1.SetPowerTargetResponse"> & { + /** + * @generated from field: string batch_identifier = 1; + */ + batchIdentifier: string; +}; + +/** + * Describes the message minercommand.v1.SetPowerTargetResponse. + * Use `create(SetPowerTargetResponseSchema)` to create a new message. + */ +export const SetPowerTargetResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 11); + +/** + * Configuration for a single pool slot + * Can specify either a Fleet pool ID (for known pools) or raw pool info (for unknown pools) + * + * @generated from message minercommand.v1.PoolSlotConfig + */ +export type PoolSlotConfig = Message<"minercommand.v1.PoolSlotConfig"> & { + /** + * @generated from oneof minercommand.v1.PoolSlotConfig.pool_source + */ + poolSource: + | { + /** + * Fleet pool ID - used when the pool exists in Fleet's database + * + * @generated from field: int64 pool_id = 1; + */ + value: bigint; + case: "poolId"; + } + | { + /** + * Raw pool info - used when the pool is configured on the miner but not in Fleet + * + * @generated from field: minercommand.v1.RawPoolInfo raw_pool = 2; + */ + value: RawPoolInfo; + case: "rawPool"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message minercommand.v1.PoolSlotConfig. + * Use `create(PoolSlotConfigSchema)` to create a new message. + */ +export const PoolSlotConfigSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 12); + +/** + * Raw pool configuration for pools not stored in Fleet + * + * @generated from message minercommand.v1.RawPoolInfo + */ +export type RawPoolInfo = Message<"minercommand.v1.RawPoolInfo"> & { + /** + * @generated from field: string url = 1; + */ + url: string; + + /** + * @generated from field: string username = 2; + */ + username: string; + + /** + * Password is optional since miners don't expose it + * + * @generated from field: optional string password = 3; + */ + password?: string; +}; + +/** + * Describes the message minercommand.v1.RawPoolInfo. + * Use `create(RawPoolInfoSchema)` to create a new message. + */ +export const RawPoolInfoSchema: GenMessage = /*@__PURE__*/ messageDesc(file_minercommand_v1_command, 13); + +/** + * @generated from message minercommand.v1.UpdateMiningPoolsRequest + */ +export type UpdateMiningPoolsRequest = Message<"minercommand.v1.UpdateMiningPoolsRequest"> & { + /** + * @generated from field: minercommand.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; + + /** + * Default pool (priority 0) - required + * + * @generated from field: minercommand.v1.PoolSlotConfig default_pool = 2; + */ + defaultPool?: PoolSlotConfig; + + /** + * Backup pool 1 (priority 1) - optional + * + * @generated from field: optional minercommand.v1.PoolSlotConfig backup_1_pool = 3; + */ + backup1Pool?: PoolSlotConfig; + + /** + * Backup pool 2 (priority 2) - optional + * + * @generated from field: optional minercommand.v1.PoolSlotConfig backup_2_pool = 4; + */ + backup2Pool?: PoolSlotConfig; + + /** + * Fleet user's username for authorization + * + * @generated from field: string user_username = 5; + */ + userUsername: string; + + /** + * Fleet user's password for authorization + * + * @generated from field: string user_password = 6; + */ + userPassword: string; +}; + +/** + * Describes the message minercommand.v1.UpdateMiningPoolsRequest. + * Use `create(UpdateMiningPoolsRequestSchema)` to create a new message. + */ +export const UpdateMiningPoolsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 14); + +/** + * @generated from message minercommand.v1.UpdateMiningPoolsResponse + */ +export type UpdateMiningPoolsResponse = Message<"minercommand.v1.UpdateMiningPoolsResponse"> & { + /** + * @generated from field: string batch_identifier = 1; + */ + batchIdentifier: string; +}; + +/** + * Describes the message minercommand.v1.UpdateMiningPoolsResponse. + * Use `create(UpdateMiningPoolsResponseSchema)` to create a new message. + */ +export const UpdateMiningPoolsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 15); + +/** + * @generated from message minercommand.v1.DownloadLogsRequest + */ +export type DownloadLogsRequest = Message<"minercommand.v1.DownloadLogsRequest"> & { + /** + * @generated from field: minercommand.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message minercommand.v1.DownloadLogsRequest. + * Use `create(DownloadLogsRequestSchema)` to create a new message. + */ +export const DownloadLogsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 16); + +/** + * @generated from message minercommand.v1.DownloadLogsResponse + */ +export type DownloadLogsResponse = Message<"minercommand.v1.DownloadLogsResponse"> & { + /** + * @generated from field: string batch_identifier = 1; + */ + batchIdentifier: string; +}; + +/** + * Describes the message minercommand.v1.DownloadLogsResponse. + * Use `create(DownloadLogsResponseSchema)` to create a new message. + */ +export const DownloadLogsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 17); + +/** + * @generated from message minercommand.v1.BlinkLEDRequest + */ +export type BlinkLEDRequest = Message<"minercommand.v1.BlinkLEDRequest"> & { + /** + * @generated from field: minercommand.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message minercommand.v1.BlinkLEDRequest. + * Use `create(BlinkLEDRequestSchema)` to create a new message. + */ +export const BlinkLEDRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 18); + +/** + * @generated from message minercommand.v1.BlinkLEDResponse + */ +export type BlinkLEDResponse = Message<"minercommand.v1.BlinkLEDResponse"> & { + /** + * @generated from field: string batch_identifier = 1; + */ + batchIdentifier: string; +}; + +/** + * Describes the message minercommand.v1.BlinkLEDResponse. + * Use `create(BlinkLEDResponseSchema)` to create a new message. + */ +export const BlinkLEDResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 19); + +/** + * @generated from message minercommand.v1.FirmwareUpdateRequest + */ +export type FirmwareUpdateRequest = Message<"minercommand.v1.FirmwareUpdateRequest"> & { + /** + * @generated from field: minercommand.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; + + /** + * Reference to a firmware file previously uploaded via the firmware upload HTTP endpoint + * + * @generated from field: string firmware_file_id = 2; + */ + firmwareFileId: string; +}; + +/** + * Describes the message minercommand.v1.FirmwareUpdateRequest. + * Use `create(FirmwareUpdateRequestSchema)` to create a new message. + */ +export const FirmwareUpdateRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 20); + +/** + * @generated from message minercommand.v1.FirmwareUpdateResponse + */ +export type FirmwareUpdateResponse = Message<"minercommand.v1.FirmwareUpdateResponse"> & { + /** + * @generated from field: string batch_identifier = 1; + */ + batchIdentifier: string; +}; + +/** + * Describes the message minercommand.v1.FirmwareUpdateResponse. + * Use `create(FirmwareUpdateResponseSchema)` to create a new message. + */ +export const FirmwareUpdateResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 21); + +/** + * @generated from message minercommand.v1.UnpairRequest + */ +export type UnpairRequest = Message<"minercommand.v1.UnpairRequest"> & { + /** + * @generated from field: minercommand.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message minercommand.v1.UnpairRequest. + * Use `create(UnpairRequestSchema)` to create a new message. + */ +export const UnpairRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 22); + +/** + * @generated from message minercommand.v1.UnpairResponse + */ +export type UnpairResponse = Message<"minercommand.v1.UnpairResponse"> & { + /** + * @generated from field: string batch_identifier = 1; + */ + batchIdentifier: string; +}; + +/** + * Describes the message minercommand.v1.UnpairResponse. + * Use `create(UnpairResponseSchema)` to create a new message. + */ +export const UnpairResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 23); + +/** + * Updates miner web UI password + * + * @generated from message minercommand.v1.UpdateMinerPasswordRequest + */ +export type UpdateMinerPasswordRequest = Message<"minercommand.v1.UpdateMinerPasswordRequest"> & { + /** + * @generated from field: minercommand.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; + + /** + * New password for miner web UI access + * + * @generated from field: string new_password = 2; + */ + newPassword: string; + + /** + * Current password for verification (required by miner APIs) + * + * @generated from field: string current_password = 3; + */ + currentPassword: string; + + /** + * Fleet user's username for authorization + * + * @generated from field: string user_username = 4; + */ + userUsername: string; + + /** + * Fleet user's password for authorization + * + * @generated from field: string user_password = 5; + */ + userPassword: string; +}; + +/** + * Describes the message minercommand.v1.UpdateMinerPasswordRequest. + * Use `create(UpdateMinerPasswordRequestSchema)` to create a new message. + */ +export const UpdateMinerPasswordRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 24); + +/** + * @generated from message minercommand.v1.UpdateMinerPasswordResponse + */ +export type UpdateMinerPasswordResponse = Message<"minercommand.v1.UpdateMinerPasswordResponse"> & { + /** + * @generated from field: string batch_identifier = 1; + */ + batchIdentifier: string; +}; + +/** + * Describes the message minercommand.v1.UpdateMinerPasswordResponse. + * Use `create(UpdateMinerPasswordResponseSchema)` to create a new message. + */ +export const UpdateMinerPasswordResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 25); + +/** + * @generated from message minercommand.v1.StreamCommandBatchUpdatesRequest + */ +export type StreamCommandBatchUpdatesRequest = Message<"minercommand.v1.StreamCommandBatchUpdatesRequest"> & { + /** + * The identifier of the command batch to stream + * + * @generated from field: string batch_identifier = 1; + */ + batchIdentifier: string; +}; + +/** + * Describes the message minercommand.v1.StreamCommandBatchUpdatesRequest. + * Use `create(StreamCommandBatchUpdatesRequestSchema)` to create a new message. + */ +export const StreamCommandBatchUpdatesRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 26); + +/** + * @generated from message minercommand.v1.StreamCommandBatchUpdatesResponse + */ +export type StreamCommandBatchUpdatesResponse = Message<"minercommand.v1.StreamCommandBatchUpdatesResponse"> & { + /** + * Timestamp when this update was generated + * + * @generated from field: google.protobuf.Timestamp timestamp = 1; + */ + timestamp?: Timestamp; + + /** + * Identifier of the command batch this update is for + * + * @generated from field: string command_batch_identifier = 2; + */ + commandBatchIdentifier: string; + + /** + * @generated from field: minercommand.v1.CommandBatchUpdateStatus status = 3; + */ + status?: CommandBatchUpdateStatus; +}; + +/** + * Describes the message minercommand.v1.StreamCommandBatchUpdatesResponse. + * Use `create(StreamCommandBatchUpdatesResponseSchema)` to create a new message. + */ +export const StreamCommandBatchUpdatesResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 27); + +/** + * @generated from message minercommand.v1.CommandBatchUpdateDeviceCount + */ +export type CommandBatchUpdateDeviceCount = Message<"minercommand.v1.CommandBatchUpdateDeviceCount"> & { + /** + * @generated from field: int64 total = 1; + */ + total: bigint; + + /** + * @generated from field: int64 success = 2; + */ + success: bigint; + + /** + * @generated from field: int64 failure = 3; + */ + failure: bigint; + + /** + * @generated from field: repeated string success_device_identifiers = 4; + */ + successDeviceIdentifiers: string[]; + + /** + * @generated from field: repeated string failure_device_identifiers = 5; + */ + failureDeviceIdentifiers: string[]; +}; + +/** + * Describes the message minercommand.v1.CommandBatchUpdateDeviceCount. + * Use `create(CommandBatchUpdateDeviceCountSchema)` to create a new message. + */ +export const CommandBatchUpdateDeviceCountSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 28); + +/** + * @generated from message minercommand.v1.CommandBatchUpdateStatus + */ +export type CommandBatchUpdateStatus = Message<"minercommand.v1.CommandBatchUpdateStatus"> & { + /** + * @generated from field: minercommand.v1.CommandBatchUpdateStatus.CommandBatchUpdateStatusType command_batch_update_status = 1; + */ + commandBatchUpdateStatus: CommandBatchUpdateStatus_CommandBatchUpdateStatusType; + + /** + * @generated from field: minercommand.v1.CommandBatchUpdateDeviceCount command_batch_device_count = 2; + */ + commandBatchDeviceCount?: CommandBatchUpdateDeviceCount; +}; + +/** + * Describes the message minercommand.v1.CommandBatchUpdateStatus. + * Use `create(CommandBatchUpdateStatusSchema)` to create a new message. + */ +export const CommandBatchUpdateStatusSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 29); + +/** + * @generated from enum minercommand.v1.CommandBatchUpdateStatus.CommandBatchUpdateStatusType + */ +export enum CommandBatchUpdateStatus_CommandBatchUpdateStatusType { + /** + * @generated from enum value: COMMAND_BATCH_UPDATE_STATUS_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: COMMAND_BATCH_UPDATE_STATUS_TYPE_PENDING = 1; + */ + PENDING = 1, + + /** + * @generated from enum value: COMMAND_BATCH_UPDATE_STATUS_TYPE_PROCESSING = 2; + */ + PROCESSING = 2, + + /** + * @generated from enum value: COMMAND_BATCH_UPDATE_STATUS_TYPE_FINISHED = 3; + */ + FINISHED = 3, +} + +/** + * Describes the enum minercommand.v1.CommandBatchUpdateStatus.CommandBatchUpdateStatusType. + */ +export const CommandBatchUpdateStatus_CommandBatchUpdateStatusTypeSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_minercommand_v1_command, 29, 0); + +/** + * @generated from message minercommand.v1.GetCommandBatchLogBundleRequest + */ +export type GetCommandBatchLogBundleRequest = Message<"minercommand.v1.GetCommandBatchLogBundleRequest"> & { + /** + * @generated from field: string batch_identifier = 1; + */ + batchIdentifier: string; +}; + +/** + * Describes the message minercommand.v1.GetCommandBatchLogBundleRequest. + * Use `create(GetCommandBatchLogBundleRequestSchema)` to create a new message. + */ +export const GetCommandBatchLogBundleRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 30); + +/** + * @generated from message minercommand.v1.GetCommandBatchLogBundleResponse + */ +export type GetCommandBatchLogBundleResponse = Message<"minercommand.v1.GetCommandBatchLogBundleResponse"> & { + /** + * @generated from field: bytes chunk_data = 1; + */ + chunkData: Uint8Array; + + /** + * @generated from field: string filename = 2; + */ + filename: string; +}; + +/** + * Describes the message minercommand.v1.GetCommandBatchLogBundleResponse. + * Use `create(GetCommandBatchLogBundleResponseSchema)` to create a new message. + */ +export const GetCommandBatchLogBundleResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 31); + +/** + * Request to check command capabilities for selected devices + * + * @generated from message minercommand.v1.CheckCommandCapabilitiesRequest + */ +export type CheckCommandCapabilitiesRequest = Message<"minercommand.v1.CheckCommandCapabilitiesRequest"> & { + /** + * @generated from field: minercommand.v1.CommandType command_type = 1; + */ + commandType: CommandType; + + /** + * @generated from field: minercommand.v1.DeviceSelector device_selector = 2; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message minercommand.v1.CheckCommandCapabilitiesRequest. + * Use `create(CheckCommandCapabilitiesRequestSchema)` to create a new message. + */ +export const CheckCommandCapabilitiesRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 32); + +/** + * Group of unsupported miners with same firmware and model + * + * @generated from message minercommand.v1.UnsupportedMinerGroup + */ +export type UnsupportedMinerGroup = Message<"minercommand.v1.UnsupportedMinerGroup"> & { + /** + * @generated from field: string firmware_version = 1; + */ + firmwareVersion: string; + + /** + * @generated from field: string model = 2; + */ + model: string; + + /** + * @generated from field: int32 count = 3; + */ + count: number; +}; + +/** + * Describes the message minercommand.v1.UnsupportedMinerGroup. + * Use `create(UnsupportedMinerGroupSchema)` to create a new message. + */ +export const UnsupportedMinerGroupSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 33); + +/** + * Response with capability check results + * + * @generated from message minercommand.v1.CheckCommandCapabilitiesResponse + */ +export type CheckCommandCapabilitiesResponse = Message<"minercommand.v1.CheckCommandCapabilitiesResponse"> & { + /** + * @generated from field: int32 supported_count = 1; + */ + supportedCount: number; + + /** + * @generated from field: int32 unsupported_count = 2; + */ + unsupportedCount: number; + + /** + * @generated from field: int32 total_count = 3; + */ + totalCount: number; + + /** + * @generated from field: bool all_supported = 4; + */ + allSupported: boolean; + + /** + * @generated from field: bool none_supported = 5; + */ + noneSupported: boolean; + + /** + * @generated from field: repeated minercommand.v1.UnsupportedMinerGroup unsupported_groups = 6; + */ + unsupportedGroups: UnsupportedMinerGroup[]; + + /** + * Device identifiers that support the command (for filtered execution) + * + * @generated from field: repeated string supported_device_identifiers = 7; + */ + supportedDeviceIdentifiers: string[]; +}; + +/** + * Describes the message minercommand.v1.CheckCommandCapabilitiesResponse. + * Use `create(CheckCommandCapabilitiesResponseSchema)` to create a new message. + */ +export const CheckCommandCapabilitiesResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 34); + +/** + * @generated from enum minercommand.v1.PerformanceMode + */ +export enum PerformanceMode { + /** + * @generated from enum value: PERFORMANCE_MODE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: PERFORMANCE_MODE_MAXIMUM_HASHRATE = 1; + */ + MAXIMUM_HASHRATE = 1, + + /** + * @generated from enum value: PERFORMANCE_MODE_EFFICIENCY = 2; + */ + EFFICIENCY = 2, +} + +/** + * Describes the enum minercommand.v1.PerformanceMode. + */ +export const PerformanceModeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_minercommand_v1_command, 0); + +/** + * Command type enum for capability checking + * + * @generated from enum minercommand.v1.CommandType + */ +export enum CommandType { + /** + * @generated from enum value: COMMAND_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: COMMAND_TYPE_REBOOT = 1; + */ + REBOOT = 1, + + /** + * @generated from enum value: COMMAND_TYPE_START_MINING = 2; + */ + START_MINING = 2, + + /** + * @generated from enum value: COMMAND_TYPE_STOP_MINING = 3; + */ + STOP_MINING = 3, + + /** + * @generated from enum value: COMMAND_TYPE_BLINK_LED = 4; + */ + BLINK_LED = 4, + + /** + * @generated from enum value: COMMAND_TYPE_SET_COOLING_MODE = 5; + */ + SET_COOLING_MODE = 5, + + /** + * @generated from enum value: COMMAND_TYPE_UPDATE_MINING_POOLS = 6; + */ + UPDATE_MINING_POOLS = 6, + + /** + * @generated from enum value: COMMAND_TYPE_DOWNLOAD_LOGS = 7; + */ + DOWNLOAD_LOGS = 7, + + /** + * @generated from enum value: COMMAND_TYPE_FIRMWARE_UPDATE = 8; + */ + FIRMWARE_UPDATE = 8, + + /** + * @generated from enum value: COMMAND_TYPE_SET_POWER_TARGET = 9; + */ + SET_POWER_TARGET = 9, + + /** + * @generated from enum value: COMMAND_TYPE_UPDATE_MINER_PASSWORD = 10; + */ + UPDATE_MINER_PASSWORD = 10, +} + +/** + * Describes the enum minercommand.v1.CommandType. + */ +export const CommandTypeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_minercommand_v1_command, 1); + +/** + * Service for executing Miner commands + * + * @generated from service minercommand.v1.MinerCommandService + */ +export const MinerCommandService: GenService<{ + /** + * @generated from rpc minercommand.v1.MinerCommandService.Reboot + */ + reboot: { + methodKind: "unary"; + input: typeof RebootRequestSchema; + output: typeof RebootResponseSchema; + }; + /** + * Stops mining on specified miners + * The operation is attempted on all miners even if some fail + * + * @generated from rpc minercommand.v1.MinerCommandService.StopMining + */ + stopMining: { + methodKind: "unary"; + input: typeof StopMiningRequestSchema; + output: typeof StopMiningResponseSchema; + }; + /** + * Starts mining on specified miners + * The operation is attempted on all miners even if some fail + * + * @generated from rpc minercommand.v1.MinerCommandService.StartMining + */ + startMining: { + methodKind: "unary"; + input: typeof StartMiningRequestSchema; + output: typeof StartMiningResponseSchema; + }; + /** + * @generated from rpc minercommand.v1.MinerCommandService.SetCoolingMode + */ + setCoolingMode: { + methodKind: "unary"; + input: typeof SetCoolingModeRequestSchema; + output: typeof SetCoolingModeResponseSchema; + }; + /** + * @generated from rpc minercommand.v1.MinerCommandService.SetPowerTarget + */ + setPowerTarget: { + methodKind: "unary"; + input: typeof SetPowerTargetRequestSchema; + output: typeof SetPowerTargetResponseSchema; + }; + /** + * @generated from rpc minercommand.v1.MinerCommandService.UpdateMiningPools + */ + updateMiningPools: { + methodKind: "unary"; + input: typeof UpdateMiningPoolsRequestSchema; + output: typeof UpdateMiningPoolsResponseSchema; + }; + /** + * @generated from rpc minercommand.v1.MinerCommandService.DownloadLogs + */ + downloadLogs: { + methodKind: "unary"; + input: typeof DownloadLogsRequestSchema; + output: typeof DownloadLogsResponseSchema; + }; + /** + * @generated from rpc minercommand.v1.MinerCommandService.BlinkLED + */ + blinkLED: { + methodKind: "unary"; + input: typeof BlinkLEDRequestSchema; + output: typeof BlinkLEDResponseSchema; + }; + /** + * Streams command batch updates + * + * @generated from rpc minercommand.v1.MinerCommandService.StreamCommandBatchUpdates + */ + streamCommandBatchUpdates: { + methodKind: "server_streaming"; + input: typeof StreamCommandBatchUpdatesRequestSchema; + output: typeof StreamCommandBatchUpdatesResponseSchema; + }; + /** + * @generated from rpc minercommand.v1.MinerCommandService.GetCommandBatchLogBundle + */ + getCommandBatchLogBundle: { + methodKind: "unary"; + input: typeof GetCommandBatchLogBundleRequestSchema; + output: typeof GetCommandBatchLogBundleResponseSchema; + }; + /** + * @generated from rpc minercommand.v1.MinerCommandService.FirmwareUpdate + */ + firmwareUpdate: { + methodKind: "unary"; + input: typeof FirmwareUpdateRequestSchema; + output: typeof FirmwareUpdateResponseSchema; + }; + /** + * Unpairs devices from the fleet + * Updates pairing status to UNPAIRED and clears credentials on the device + * + * @generated from rpc minercommand.v1.MinerCommandService.Unpair + */ + unpair: { + methodKind: "unary"; + input: typeof UnpairRequestSchema; + output: typeof UnpairResponseSchema; + }; + /** + * Updates miner web UI password on specified miners + * The operation is attempted on all miners even if some fail + * + * @generated from rpc minercommand.v1.MinerCommandService.UpdateMinerPassword + */ + updateMinerPassword: { + methodKind: "unary"; + input: typeof UpdateMinerPasswordRequestSchema; + output: typeof UpdateMinerPasswordResponseSchema; + }; + /** + * Checks if selected devices support a command before execution + * Returns capability check results with unsupported miners grouped by model/firmware + * + * @generated from rpc minercommand.v1.MinerCommandService.CheckCommandCapabilities + */ + checkCommandCapabilities: { + methodKind: "unary"; + input: typeof CheckCommandCapabilitiesRequestSchema; + output: typeof CheckCommandCapabilitiesResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_minercommand_v1_command, 0); diff --git a/client/src/protoFleet/api/generated/networkinfo/v1/networkinfo_pb.ts b/client/src/protoFleet/api/generated/networkinfo/v1/networkinfo_pb.ts new file mode 100644 index 000000000..661d634b9 --- /dev/null +++ b/client/src/protoFleet/api/generated/networkinfo/v1/networkinfo_pb.ts @@ -0,0 +1,175 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file networkinfo/v1/networkinfo.proto (package networkinfo.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file networkinfo/v1/networkinfo.proto. + */ +export const file_networkinfo_v1_networkinfo: GenFile = + /*@__PURE__*/ + fileDesc( + "CiBuZXR3b3JraW5mby92MS9uZXR3b3JraW5mby5wcm90bxIObmV0d29ya2luZm8udjEigwEKC05ldHdvcmtJbmZvEhgKEG5ldHdvcmtfbmlja25hbWUYASABKAkSEAoIbG9jYWxfaXAYAiABKAkSDwoHZ2F0ZXdheRgDIAEoCRIOCgZzdWJuZXQYBCABKAkSEgoKbG9jYWxfaXB2NhgFIAEoCRITCgtpcHY2X3N1Ym5ldBgGIAEoCSIXChVHZXROZXR3b3JrSW5mb1JlcXVlc3QiSwoWR2V0TmV0d29ya0luZm9SZXNwb25zZRIxCgxuZXR3b3JrX2luZm8YASABKAsyGy5uZXR3b3JraW5mby52MS5OZXR3b3JrSW5mbyI4ChxVcGRhdGVOZXR3b3JrTmlja25hbWVSZXF1ZXN0EhgKEG5ldHdvcmtfbmlja25hbWUYASABKAkiHwodVXBkYXRlTmV0d29ya05pY2tuYW1lUmVzcG9uc2Uy6wEKEk5ldHdvcmtJbmZvU2VydmljZRJfCg5HZXROZXR3b3JrSW5mbxIlLm5ldHdvcmtpbmZvLnYxLkdldE5ldHdvcmtJbmZvUmVxdWVzdBomLm5ldHdvcmtpbmZvLnYxLkdldE5ldHdvcmtJbmZvUmVzcG9uc2USdAoVVXBkYXRlTmV0d29ya05pY2tuYW1lEiwubmV0d29ya2luZm8udjEuVXBkYXRlTmV0d29ya05pY2tuYW1lUmVxdWVzdBotLm5ldHdvcmtpbmZvLnYxLlVwZGF0ZU5ldHdvcmtOaWNrbmFtZVJlc3BvbnNlQtABChJjb20ubmV0d29ya2luZm8udjFCEE5ldHdvcmtpbmZvUHJvdG9QAVpPZ2l0aHViLmNvbS9ibG9jay9wcm90by1mbGVldC9zZXJ2ZXIvZ2VuZXJhdGVkL2dycGMvbmV0d29ya2luZm8vdjE7bmV0d29ya2luZm92MaICA05YWKoCDk5ldHdvcmtpbmZvLlYxygIOTmV0d29ya2luZm9cVjHiAhpOZXR3b3JraW5mb1xWMVxHUEJNZXRhZGF0YeoCD05ldHdvcmtpbmZvOjpWMWIGcHJvdG8z", + ); + +/** + * NetworkInfo represents the complete network configuration and identification details + * + * @generated from message networkinfo.v1.NetworkInfo + */ +export type NetworkInfo = Message<"networkinfo.v1.NetworkInfo"> & { + /** + * User-defined nickname for the network + * + * @generated from field: string network_nickname = 1; + */ + networkNickname: string; + + /** + * Local IP address assigned to this device on the network + * + * @generated from field: string local_ip = 2; + */ + localIp: string; + + /** + * Gateway IP address for the network + * + * @generated from field: string gateway = 3; + */ + gateway: string; + + /** + * Subnet mask or CIDR notation for the network + * + * @generated from field: string subnet = 4; + */ + subnet: string; + + /** + * Local IPv6 address assigned to this device on the network (empty if unavailable) + * + * @generated from field: string local_ipv6 = 5; + */ + localIpv6: string; + + /** + * IPv6 subnet in CIDR notation (empty if unavailable) + * + * @generated from field: string ipv6_subnet = 6; + */ + ipv6Subnet: string; +}; + +/** + * Describes the message networkinfo.v1.NetworkInfo. + * Use `create(NetworkInfoSchema)` to create a new message. + */ +export const NetworkInfoSchema: GenMessage = /*@__PURE__*/ messageDesc(file_networkinfo_v1_networkinfo, 0); + +/** + * Request to retrieve current network information + * Empty message as no parameters are needed + * + * @generated from message networkinfo.v1.GetNetworkInfoRequest + */ +export type GetNetworkInfoRequest = Message<"networkinfo.v1.GetNetworkInfoRequest"> & {}; + +/** + * Describes the message networkinfo.v1.GetNetworkInfoRequest. + * Use `create(GetNetworkInfoRequestSchema)` to create a new message. + */ +export const GetNetworkInfoRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_networkinfo_v1_networkinfo, 1); + +/** + * Response containing the current network information + * + * @generated from message networkinfo.v1.GetNetworkInfoResponse + */ +export type GetNetworkInfoResponse = Message<"networkinfo.v1.GetNetworkInfoResponse"> & { + /** + * Complete network information details + * + * @generated from field: networkinfo.v1.NetworkInfo network_info = 1; + */ + networkInfo?: NetworkInfo; +}; + +/** + * Describes the message networkinfo.v1.GetNetworkInfoResponse. + * Use `create(GetNetworkInfoResponseSchema)` to create a new message. + */ +export const GetNetworkInfoResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_networkinfo_v1_networkinfo, 2); + +/** + * Request to update the user-defined nickname for the network + * + * @generated from message networkinfo.v1.UpdateNetworkNicknameRequest + */ +export type UpdateNetworkNicknameRequest = Message<"networkinfo.v1.UpdateNetworkNicknameRequest"> & { + /** + * New nickname to assign to the network + * + * @generated from field: string network_nickname = 1; + */ + networkNickname: string; +}; + +/** + * Describes the message networkinfo.v1.UpdateNetworkNicknameRequest. + * Use `create(UpdateNetworkNicknameRequestSchema)` to create a new message. + */ +export const UpdateNetworkNicknameRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_networkinfo_v1_networkinfo, 3); + +/** + * Response to network nickname update request + * Empty message as no return data is needed + * + * @generated from message networkinfo.v1.UpdateNetworkNicknameResponse + */ +export type UpdateNetworkNicknameResponse = Message<"networkinfo.v1.UpdateNetworkNicknameResponse"> & {}; + +/** + * Describes the message networkinfo.v1.UpdateNetworkNicknameResponse. + * Use `create(UpdateNetworkNicknameResponseSchema)` to create a new message. + */ +export const UpdateNetworkNicknameResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_networkinfo_v1_networkinfo, 4); + +/** + * Service for managing and retrieving network information + * + * @generated from service networkinfo.v1.NetworkInfoService + */ +export const NetworkInfoService: GenService<{ + /** + * Retrieves the current network configuration and status + * + * @generated from rpc networkinfo.v1.NetworkInfoService.GetNetworkInfo + */ + getNetworkInfo: { + methodKind: "unary"; + input: typeof GetNetworkInfoRequestSchema; + output: typeof GetNetworkInfoResponseSchema; + }; + /** + * Updates the user-defined nickname for the current network + * + * @generated from rpc networkinfo.v1.NetworkInfoService.UpdateNetworkNickname + */ + updateNetworkNickname: { + methodKind: "unary"; + input: typeof UpdateNetworkNicknameRequestSchema; + output: typeof UpdateNetworkNicknameResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_networkinfo_v1_networkinfo, 0); diff --git a/client/src/protoFleet/api/generated/onboarding/v1/onboarding_pb.ts b/client/src/protoFleet/api/generated/onboarding/v1/onboarding_pb.ts new file mode 100644 index 000000000..c23990ae7 --- /dev/null +++ b/client/src/protoFleet/api/generated/onboarding/v1/onboarding_pb.ts @@ -0,0 +1,190 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file onboarding/v1/onboarding.proto (package onboarding.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file onboarding/v1/onboarding.proto. + */ +export const file_onboarding_v1_onboarding: GenFile = + /*@__PURE__*/ + fileDesc( + "Ch5vbmJvYXJkaW5nL3YxL29uYm9hcmRpbmcucHJvdG8SDW9uYm9hcmRpbmcudjEiPQoXQ3JlYXRlQWRtaW5Mb2dpblJlcXVlc3QSEAoIdXNlcm5hbWUYASABKAkSEAoIcGFzc3dvcmQYAiABKAkiKwoYQ3JlYXRlQWRtaW5Mb2dpblJlc3BvbnNlEg8KB3VzZXJfaWQYASABKAkiGwoZR2V0RmxlZXRJbml0U3RhdHVzUmVxdWVzdCJMChpHZXRGbGVldEluaXRTdGF0dXNSZXNwb25zZRIuCgZzdGF0dXMYASABKAsyHi5vbmJvYXJkaW5nLnYxLkZsZWV0SW5pdFN0YXR1cyIoCg9GbGVldEluaXRTdGF0dXMSFQoNYWRtaW5fY3JlYXRlZBgBIAEoCCIhCh9HZXRGbGVldE9uYm9hcmRpbmdTdGF0dXNSZXF1ZXN0IlgKIEdldEZsZWV0T25ib2FyZGluZ1N0YXR1c1Jlc3BvbnNlEjQKBnN0YXR1cxgBIAEoCzIkLm9uYm9hcmRpbmcudjEuRmxlZXRPbmJvYXJkaW5nU3RhdHVzIkcKFUZsZWV0T25ib2FyZGluZ1N0YXR1cxIXCg9wb29sX2NvbmZpZ3VyZWQYASABKAgSFQoNZGV2aWNlX3BhaXJlZBgCIAEoCDLgAgoRT25ib2FyZGluZ1NlcnZpY2USYwoQQ3JlYXRlQWRtaW5Mb2dpbhImLm9uYm9hcmRpbmcudjEuQ3JlYXRlQWRtaW5Mb2dpblJlcXVlc3QaJy5vbmJvYXJkaW5nLnYxLkNyZWF0ZUFkbWluTG9naW5SZXNwb25zZRJpChJHZXRGbGVldEluaXRTdGF0dXMSKC5vbmJvYXJkaW5nLnYxLkdldEZsZWV0SW5pdFN0YXR1c1JlcXVlc3QaKS5vbmJvYXJkaW5nLnYxLkdldEZsZWV0SW5pdFN0YXR1c1Jlc3BvbnNlEnsKGEdldEZsZWV0T25ib2FyZGluZ1N0YXR1cxIuLm9uYm9hcmRpbmcudjEuR2V0RmxlZXRPbmJvYXJkaW5nU3RhdHVzUmVxdWVzdBovLm9uYm9hcmRpbmcudjEuR2V0RmxlZXRPbmJvYXJkaW5nU3RhdHVzUmVzcG9uc2VCyAEKEWNvbS5vbmJvYXJkaW5nLnYxQg9PbmJvYXJkaW5nUHJvdG9QAVpNZ2l0aHViLmNvbS9ibG9jay9wcm90by1mbGVldC9zZXJ2ZXIvZ2VuZXJhdGVkL2dycGMvb25ib2FyZGluZy92MTtvbmJvYXJkaW5ndjGiAgNPWFiqAg1PbmJvYXJkaW5nLlYxygINT25ib2FyZGluZ1xWMeICGU9uYm9hcmRpbmdcVjFcR1BCTWV0YWRhdGHqAg5PbmJvYXJkaW5nOjpWMWIGcHJvdG8z", + ); + +/** + * @generated from message onboarding.v1.CreateAdminLoginRequest + */ +export type CreateAdminLoginRequest = Message<"onboarding.v1.CreateAdminLoginRequest"> & { + /** + * @generated from field: string username = 1; + */ + username: string; + + /** + * @generated from field: string password = 2; + */ + password: string; +}; + +/** + * Describes the message onboarding.v1.CreateAdminLoginRequest. + * Use `create(CreateAdminLoginRequestSchema)` to create a new message. + */ +export const CreateAdminLoginRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_onboarding_v1_onboarding, 0); + +/** + * @generated from message onboarding.v1.CreateAdminLoginResponse + */ +export type CreateAdminLoginResponse = Message<"onboarding.v1.CreateAdminLoginResponse"> & { + /** + * @generated from field: string user_id = 1; + */ + userId: string; +}; + +/** + * Describes the message onboarding.v1.CreateAdminLoginResponse. + * Use `create(CreateAdminLoginResponseSchema)` to create a new message. + */ +export const CreateAdminLoginResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_onboarding_v1_onboarding, 1); + +/** + * @generated from message onboarding.v1.GetFleetInitStatusRequest + */ +export type GetFleetInitStatusRequest = Message<"onboarding.v1.GetFleetInitStatusRequest"> & {}; + +/** + * Describes the message onboarding.v1.GetFleetInitStatusRequest. + * Use `create(GetFleetInitStatusRequestSchema)` to create a new message. + */ +export const GetFleetInitStatusRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_onboarding_v1_onboarding, 2); + +/** + * @generated from message onboarding.v1.GetFleetInitStatusResponse + */ +export type GetFleetInitStatusResponse = Message<"onboarding.v1.GetFleetInitStatusResponse"> & { + /** + * @generated from field: onboarding.v1.FleetInitStatus status = 1; + */ + status?: FleetInitStatus; +}; + +/** + * Describes the message onboarding.v1.GetFleetInitStatusResponse. + * Use `create(GetFleetInitStatusResponseSchema)` to create a new message. + */ +export const GetFleetInitStatusResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_onboarding_v1_onboarding, 3); + +/** + * @generated from message onboarding.v1.FleetInitStatus + */ +export type FleetInitStatus = Message<"onboarding.v1.FleetInitStatus"> & { + /** + * @generated from field: bool admin_created = 1; + */ + adminCreated: boolean; +}; + +/** + * Describes the message onboarding.v1.FleetInitStatus. + * Use `create(FleetInitStatusSchema)` to create a new message. + */ +export const FleetInitStatusSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_onboarding_v1_onboarding, 4); + +/** + * @generated from message onboarding.v1.GetFleetOnboardingStatusRequest + */ +export type GetFleetOnboardingStatusRequest = Message<"onboarding.v1.GetFleetOnboardingStatusRequest"> & {}; + +/** + * Describes the message onboarding.v1.GetFleetOnboardingStatusRequest. + * Use `create(GetFleetOnboardingStatusRequestSchema)` to create a new message. + */ +export const GetFleetOnboardingStatusRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_onboarding_v1_onboarding, 5); + +/** + * @generated from message onboarding.v1.GetFleetOnboardingStatusResponse + */ +export type GetFleetOnboardingStatusResponse = Message<"onboarding.v1.GetFleetOnboardingStatusResponse"> & { + /** + * @generated from field: onboarding.v1.FleetOnboardingStatus status = 1; + */ + status?: FleetOnboardingStatus; +}; + +/** + * Describes the message onboarding.v1.GetFleetOnboardingStatusResponse. + * Use `create(GetFleetOnboardingStatusResponseSchema)` to create a new message. + */ +export const GetFleetOnboardingStatusResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_onboarding_v1_onboarding, 6); + +/** + * @generated from message onboarding.v1.FleetOnboardingStatus + */ +export type FleetOnboardingStatus = Message<"onboarding.v1.FleetOnboardingStatus"> & { + /** + * @generated from field: bool pool_configured = 1; + */ + poolConfigured: boolean; + + /** + * @generated from field: bool device_paired = 2; + */ + devicePaired: boolean; +}; + +/** + * Describes the message onboarding.v1.FleetOnboardingStatus. + * Use `create(FleetOnboardingStatusSchema)` to create a new message. + */ +export const FleetOnboardingStatusSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_onboarding_v1_onboarding, 7); + +/** + * @generated from service onboarding.v1.OnboardingService + */ +export const OnboardingService: GenService<{ + /** + * @generated from rpc onboarding.v1.OnboardingService.CreateAdminLogin + */ + createAdminLogin: { + methodKind: "unary"; + input: typeof CreateAdminLoginRequestSchema; + output: typeof CreateAdminLoginResponseSchema; + }; + /** + * @generated from rpc onboarding.v1.OnboardingService.GetFleetInitStatus + */ + getFleetInitStatus: { + methodKind: "unary"; + input: typeof GetFleetInitStatusRequestSchema; + output: typeof GetFleetInitStatusResponseSchema; + }; + /** + * @generated from rpc onboarding.v1.OnboardingService.GetFleetOnboardingStatus + */ + getFleetOnboardingStatus: { + methodKind: "unary"; + input: typeof GetFleetOnboardingStatusRequestSchema; + output: typeof GetFleetOnboardingStatusResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_onboarding_v1_onboarding, 0); diff --git a/client/src/protoFleet/api/generated/pairing/v1/pairing_pb.ts b/client/src/protoFleet/api/generated/pairing/v1/pairing_pb.ts new file mode 100644 index 000000000..69cd4afce --- /dev/null +++ b/client/src/protoFleet/api/generated/pairing/v1/pairing_pb.ts @@ -0,0 +1,433 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file pairing/v1/pairing.proto (package pairing.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { MinerCapabilities } from "../../capabilities/v1/capabilities_pb"; +import { file_capabilities_v1_capabilities } from "../../capabilities/v1/capabilities_pb"; +import type { DeviceSelector } from "../../minercommand/v1/command_pb"; +import { file_minercommand_v1_command } from "../../minercommand/v1/command_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file pairing/v1/pairing.proto. + */ +export const file_pairing_v1_pairing: GenFile = + /*@__PURE__*/ + fileDesc( + "ChhwYWlyaW5nL3YxL3BhaXJpbmcucHJvdG8SCnBhaXJpbmcudjEinwIKBkRldmljZRIZChFkZXZpY2VfaWRlbnRpZmllchgBIAEoCRISCgppcF9hZGRyZXNzGAIgASgJEgwKBHBvcnQYAyABKAkSEwoLbWFjX2FkZHJlc3MYBCABKAkSFQoNc2VyaWFsX251bWJlchgFIAEoCRINCgVtb2RlbBgGIAEoCRIUCgxtYW51ZmFjdHVyZXIYByABKAkSEgoKdXJsX3NjaGVtZRgIIAEoCRI4CgxjYXBhYmlsaXRpZXMYCiABKAsyIi5jYXBhYmlsaXRpZXMudjEuTWluZXJDYXBhYmlsaXRpZXMSGAoQZmlybXdhcmVfdmVyc2lvbhgLIAEoCRITCgtkcml2ZXJfbmFtZRgMIAEoCUoECAkQClIEdHlwZSJDCgtDcmVkZW50aWFscxIQCgh1c2VybmFtZRgBIAEoCRIVCghwYXNzd29yZBgCIAEoCUgAiAEBQgsKCV9wYXNzd29yZCJQCg9NRE5TTW9kZVJlcXVlc3QSFAoMc2VydmljZV90eXBlGAEgASgJEg4KBmRvbWFpbhgCIAEoCRIXCg90aW1lb3V0X3NlY29uZHMYAyABKAUiMAoPTm1hcE1vZGVSZXF1ZXN0Eg4KBnRhcmdldBgBIAEoCRINCgVwb3J0cxgCIAMoCSJFChJJUFJhbmdlTW9kZVJlcXVlc3QSEAoIc3RhcnRfaXAYASABKAkSDgoGZW5kX2lwGAIgASgJEg0KBXBvcnRzGAMgAygJIjgKEUlQTGlzdE1vZGVSZXF1ZXN0EhQKDGlwX2FkZHJlc3NlcxgBIAMoCRINCgVwb3J0cxgCIAMoCSLZAQoPRGlzY292ZXJSZXF1ZXN0EjAKB2lwX2xpc3QYASABKAsyHS5wYWlyaW5nLnYxLklQTGlzdE1vZGVSZXF1ZXN0SAASMgoIaXBfcmFuZ2UYAiABKAsyHi5wYWlyaW5nLnYxLklQUmFuZ2VNb2RlUmVxdWVzdEgAEisKBG1kbnMYAyABKAsyGy5wYWlyaW5nLnYxLk1ETlNNb2RlUmVxdWVzdEgAEisKBG5tYXAYBCABKAsyGy5wYWlyaW5nLnYxLk5tYXBNb2RlUmVxdWVzdEgAQgYKBG1vZGUiRgoQRGlzY292ZXJSZXNwb25zZRIjCgdkZXZpY2VzGAEgAygLMhIucGFpcmluZy52MS5EZXZpY2USDQoFZXJyb3IYAiABKAkidQoLUGFpclJlcXVlc3QSLAoLY3JlZGVudGlhbHMYASABKAsyFy5wYWlyaW5nLnYxLkNyZWRlbnRpYWxzEjgKD2RldmljZV9zZWxlY3RvchgCIAEoCzIfLm1pbmVyY29tbWFuZC52MS5EZXZpY2VTZWxlY3RvciIpCgxQYWlyUmVzcG9uc2USGQoRZmFpbGVkX2RldmljZV9pZHMYASADKAkylAEKDlBhaXJpbmdTZXJ2aWNlEkcKCERpc2NvdmVyEhsucGFpcmluZy52MS5EaXNjb3ZlclJlcXVlc3QaHC5wYWlyaW5nLnYxLkRpc2NvdmVyUmVzcG9uc2UwARI5CgRQYWlyEhcucGFpcmluZy52MS5QYWlyUmVxdWVzdBoYLnBhaXJpbmcudjEuUGFpclJlc3BvbnNlQrABCg5jb20ucGFpcmluZy52MUIMUGFpcmluZ1Byb3RvUAFaR2dpdGh1Yi5jb20vYmxvY2svcHJvdG8tZmxlZXQvc2VydmVyL2dlbmVyYXRlZC9ncnBjL3BhaXJpbmcvdjE7cGFpcmluZ3YxogIDUFhYqgIKUGFpcmluZy5WMcoCClBhaXJpbmdcVjHiAhZQYWlyaW5nXFYxXEdQQk1ldGFkYXRh6gILUGFpcmluZzo6VjFiBnByb3RvMw", + [file_capabilities_v1_capabilities, file_minercommand_v1_command], + ); + +/** + * Device represents a discovered network device that can be paired with the system + * + * @generated from message pairing.v1.Device + */ +export type Device = Message<"pairing.v1.Device"> & { + /** + * unique identifier of the device + * + * @generated from field: string device_identifier = 1; + */ + deviceIdentifier: string; + + /** + * IP address of the device (IPv4 or IPv6) + * + * @generated from field: string ip_address = 2; + */ + ipAddress: string; + + /** + * Port number where the device's service is running + * + * @generated from field: string port = 3; + */ + port: string; + + /** + * MAC address of the device (format: XX:XX:XX:XX:XX:XX) + * + * @generated from field: string mac_address = 4; + */ + macAddress: string; + + /** + * Serial number of the control board of the unit + * + * @generated from field: string serial_number = 5; + */ + serialNumber: string; + + /** + * Model name/number of the device + * + * @generated from field: string model = 6; + */ + model: string; + + /** + * Name of the device manufacturer + * + * @generated from field: string manufacturer = 7; + */ + manufacturer: string; + + /** + * URL scheme of the device's web interface + * + * @generated from field: string url_scheme = 8; + */ + urlScheme: string; + + /** + * Capabilities of the device + * + * @generated from field: capabilities.v1.MinerCapabilities capabilities = 10; + */ + capabilities?: MinerCapabilities; + + /** + * Firmware version (available after discovery/pairing) + * + * @generated from field: string firmware_version = 11; + */ + firmwareVersion: string; + + /** + * Driver name identifies which plugin handles this device (e.g., "proto", "antminer"). + * Used for plugin routing; distinct from type which describes hardware identity. + * + * @generated from field: string driver_name = 12; + */ + driverName: string; +}; + +/** + * Describes the message pairing.v1.Device. + * Use `create(DeviceSchema)` to create a new message. + */ +export const DeviceSchema: GenMessage = /*@__PURE__*/ messageDesc(file_pairing_v1_pairing, 0); + +/** + * Represents login credentials used for device pairing + * + * @generated from message pairing.v1.Credentials + */ +export type Credentials = Message<"pairing.v1.Credentials"> & { + /** + * @generated from field: string username = 1; + */ + username: string; + + /** + * @generated from field: optional string password = 2; + */ + password?: string; +}; + +/** + * Describes the message pairing.v1.Credentials. + * Use `create(CredentialsSchema)` to create a new message. + */ +export const CredentialsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_pairing_v1_pairing, 1); + +/** + * Configuration for mDNS-based device discovery + * + * @generated from message pairing.v1.MDNSModeRequest + */ +export type MDNSModeRequest = Message<"pairing.v1.MDNSModeRequest"> & { + /** + * Service type to discover (e.g., "_fleet._tcp") + * Format: _servicename._protocol + * + * @generated from field: string service_type = 1; + */ + serviceType: string; + + /** + * Domain to search in (typically "local") + * + * @generated from field: string domain = 2; + */ + domain: string; + + /** + * How long to search for devices, in seconds + * + * @generated from field: int32 timeout_seconds = 3; + */ + timeoutSeconds: number; +}; + +/** + * Describes the message pairing.v1.MDNSModeRequest. + * Use `create(MDNSModeRequestSchema)` to create a new message. + */ +export const MDNSModeRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_pairing_v1_pairing, 2); + +/** + * Configuration for Nmap-based network scanning discovery + * + * @generated from message pairing.v1.NmapModeRequest + */ +export type NmapModeRequest = Message<"pairing.v1.NmapModeRequest"> & { + /** + * Target specification for scan + * Can be: single IP (192.168.1.1), hostname (device.local), + * IPv4 subnet (192.168.1.0/24), or IP range (192.168.1.1-10). + * IPv6 subnet scanning is not supported; use mDNS or IP list for IPv6 devices. + * + * @generated from field: string target = 1; + */ + target: string; + + /** + * Optional ports to scan. When omitted, the server derives canonical scan ports + * from loaded plugin metadata. If provided, these ports fully override the + * server-derived defaults. + * + * @generated from field: repeated string ports = 2; + */ + ports: string[]; +}; + +/** + * Describes the message pairing.v1.NmapModeRequest. + * Use `create(NmapModeRequestSchema)` to create a new message. + */ +export const NmapModeRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_pairing_v1_pairing, 3); + +/** + * Configuration for IP range-based device discovery + * + * @generated from message pairing.v1.IPRangeModeRequest + */ +export type IPRangeModeRequest = Message<"pairing.v1.IPRangeModeRequest"> & { + /** + * Starting IP address of the range to scan + * + * @generated from field: string start_ip = 1; + */ + startIp: string; + + /** + * Ending IP address of the range to scan + * + * @generated from field: string end_ip = 2; + */ + endIp: string; + + /** + * Optional ports to check on each IP address. When omitted, the server derives + * canonical scan ports from loaded plugin metadata. If provided, these ports + * fully override the server-derived defaults. + * + * @generated from field: repeated string ports = 3; + */ + ports: string[]; +}; + +/** + * Describes the message pairing.v1.IPRangeModeRequest. + * Use `create(IPRangeModeRequestSchema)` to create a new message. + */ +export const IPRangeModeRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_pairing_v1_pairing, 4); + +/** + * Configuration for discovering devices from a specific list of IP addresses + * + * @generated from message pairing.v1.IPListModeRequest + */ +export type IPListModeRequest = Message<"pairing.v1.IPListModeRequest"> & { + /** + * List of IP addresses (IPv4, IPv6, or hostnames) to check + * + * @generated from field: repeated string ip_addresses = 1; + */ + ipAddresses: string[]; + + /** + * Optional ports to check on each IP address. When omitted, the server derives + * canonical scan ports from loaded plugin metadata. If provided, these ports + * fully override the server-derived defaults. + * + * @generated from field: repeated string ports = 2; + */ + ports: string[]; +}; + +/** + * Describes the message pairing.v1.IPListModeRequest. + * Use `create(IPListModeRequestSchema)` to create a new message. + */ +export const IPListModeRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_pairing_v1_pairing, 5); + +/** + * Request message for device discovery, supporting multiple discovery modes + * + * @generated from message pairing.v1.DiscoverRequest + */ +export type DiscoverRequest = Message<"pairing.v1.DiscoverRequest"> & { + /** + * Only one discovery mode can be active at a time + * + * @generated from oneof pairing.v1.DiscoverRequest.mode + */ + mode: + | { + /** + * Discover from list of IPs + * + * @generated from field: pairing.v1.IPListModeRequest ip_list = 1; + */ + value: IPListModeRequest; + case: "ipList"; + } + | { + /** + * Discover in IP range + * + * @generated from field: pairing.v1.IPRangeModeRequest ip_range = 2; + */ + value: IPRangeModeRequest; + case: "ipRange"; + } + | { + /** + * Discover using mDNS + * + * @generated from field: pairing.v1.MDNSModeRequest mdns = 3; + */ + value: MDNSModeRequest; + case: "mdns"; + } + | { + /** + * Discover using Nmap + * + * @generated from field: pairing.v1.NmapModeRequest nmap = 4; + */ + value: NmapModeRequest; + case: "nmap"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message pairing.v1.DiscoverRequest. + * Use `create(DiscoverRequestSchema)` to create a new message. + */ +export const DiscoverRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_pairing_v1_pairing, 6); + +/** + * Response message containing discovered devices or errors + * + * @generated from message pairing.v1.DiscoverResponse + */ +export type DiscoverResponse = Message<"pairing.v1.DiscoverResponse"> & { + /** + * List of devices discovered in this response + * + * @generated from field: repeated pairing.v1.Device devices = 1; + */ + devices: Device[]; + + /** + * Error message if discovery failed + * Empty if discovery was successful + * + * @generated from field: string error = 2; + */ + error: string; +}; + +/** + * Describes the message pairing.v1.DiscoverResponse. + * Use `create(DiscoverResponseSchema)` to create a new message. + */ +export const DiscoverResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_pairing_v1_pairing, 7); + +/** + * Request to pair with discovered devices + * + * @generated from message pairing.v1.PairRequest + */ +export type PairRequest = Message<"pairing.v1.PairRequest"> & { + /** + * Credentials for device authentication + * + * @generated from field: pairing.v1.Credentials credentials = 1; + */ + credentials?: Credentials; + + /** + * Device selector specifies which devices to pair + * + * @generated from field: minercommand.v1.DeviceSelector device_selector = 2; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message pairing.v1.PairRequest. + * Use `create(PairRequestSchema)` to create a new message. + */ +export const PairRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_pairing_v1_pairing, 8); + +/** + * Response to pairing request + * Empty message as success/failure is indicated by gRPC status + * + * @generated from message pairing.v1.PairResponse + */ +export type PairResponse = Message<"pairing.v1.PairResponse"> & { + /** + * @generated from field: repeated string failed_device_ids = 1; + */ + failedDeviceIds: string[]; +}; + +/** + * Describes the message pairing.v1.PairResponse. + * Use `create(PairResponseSchema)` to create a new message. + */ +export const PairResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_pairing_v1_pairing, 9); + +/** + * Service for discovering and pairing with network devices + * + * @generated from service pairing.v1.PairingService + */ +export const PairingService: GenService<{ + /** + * Discovers devices on the network using the specified discovery mode + * Streams results as devices are found + * + * @generated from rpc pairing.v1.PairingService.Discover + */ + discover: { + methodKind: "server_streaming"; + input: typeof DiscoverRequestSchema; + output: typeof DiscoverResponseSchema; + }; + /** + * Initiates pairing with one or more discovered devices + * + * @generated from rpc pairing.v1.PairingService.Pair + */ + pair: { + methodKind: "unary"; + input: typeof PairRequestSchema; + output: typeof PairResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_pairing_v1_pairing, 0); diff --git a/client/src/protoFleet/api/generated/ping/v1/ping_pb.ts b/client/src/protoFleet/api/generated/ping/v1/ping_pb.ts new file mode 100644 index 000000000..b91dff436 --- /dev/null +++ b/client/src/protoFleet/api/generated/ping/v1/ping_pb.ts @@ -0,0 +1,148 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file ping/v1/ping.proto (package ping.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file ping/v1/ping.proto. + */ +export const file_ping_v1_ping: GenFile = + /*@__PURE__*/ + fileDesc( + "ChJwaW5nL3YxL3BpbmcucHJvdG8SB3BpbmcudjEiGwoLUGluZ1JlcXVlc3QSDAoEdGV4dBgBIAEoCSIcCgxQaW5nUmVzcG9uc2USDAoEdGV4dBgBIAEoCSIbCgtFY2hvUmVxdWVzdBIMCgR0ZXh0GAEgASgJIhwKDEVjaG9SZXNwb25zZRIMCgR0ZXh0GAEgASgJIiEKEVBpbmdTdHJlYW1SZXF1ZXN0EgwKBHRleHQYASABKAkiIgoSUGluZ1N0cmVhbVJlc3BvbnNlEgwKBHRleHQYASABKAkyzgEKC1BpbmdTZXJ2aWNlEjgKBFBpbmcSFC5waW5nLnYxLlBpbmdSZXF1ZXN0GhUucGluZy52MS5QaW5nUmVzcG9uc2UiA5ACARI4CgRFY2hvEhQucGluZy52MS5FY2hvUmVxdWVzdBoVLnBpbmcudjEuRWNob1Jlc3BvbnNlIgOQAgISSwoKUGluZ1N0cmVhbRIaLnBpbmcudjEuUGluZ1N0cmVhbVJlcXVlc3QaGy5waW5nLnYxLlBpbmdTdHJlYW1SZXNwb25zZSIAKAEwAUKYAQoLY29tLnBpbmcudjFCCVBpbmdQcm90b1ABWkFnaXRodWIuY29tL2Jsb2NrL3Byb3RvLWZsZWV0L3NlcnZlci9nZW5lcmF0ZWQvZ3JwYy9waW5nL3YxO3Bpbmd2MaICA1BYWKoCB1BpbmcuVjHKAgdQaW5nXFYx4gITUGluZ1xWMVxHUEJNZXRhZGF0YeoCCFBpbmc6OlYxYgZwcm90bzM", + ); + +/** + * @generated from message ping.v1.PingRequest + */ +export type PingRequest = Message<"ping.v1.PingRequest"> & { + /** + * @generated from field: string text = 1; + */ + text: string; +}; + +/** + * Describes the message ping.v1.PingRequest. + * Use `create(PingRequestSchema)` to create a new message. + */ +export const PingRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_ping_v1_ping, 0); + +/** + * @generated from message ping.v1.PingResponse + */ +export type PingResponse = Message<"ping.v1.PingResponse"> & { + /** + * @generated from field: string text = 1; + */ + text: string; +}; + +/** + * Describes the message ping.v1.PingResponse. + * Use `create(PingResponseSchema)` to create a new message. + */ +export const PingResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_ping_v1_ping, 1); + +/** + * @generated from message ping.v1.EchoRequest + */ +export type EchoRequest = Message<"ping.v1.EchoRequest"> & { + /** + * @generated from field: string text = 1; + */ + text: string; +}; + +/** + * Describes the message ping.v1.EchoRequest. + * Use `create(EchoRequestSchema)` to create a new message. + */ +export const EchoRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_ping_v1_ping, 2); + +/** + * @generated from message ping.v1.EchoResponse + */ +export type EchoResponse = Message<"ping.v1.EchoResponse"> & { + /** + * @generated from field: string text = 1; + */ + text: string; +}; + +/** + * Describes the message ping.v1.EchoResponse. + * Use `create(EchoResponseSchema)` to create a new message. + */ +export const EchoResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_ping_v1_ping, 3); + +/** + * @generated from message ping.v1.PingStreamRequest + */ +export type PingStreamRequest = Message<"ping.v1.PingStreamRequest"> & { + /** + * @generated from field: string text = 1; + */ + text: string; +}; + +/** + * Describes the message ping.v1.PingStreamRequest. + * Use `create(PingStreamRequestSchema)` to create a new message. + */ +export const PingStreamRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_ping_v1_ping, 4); + +/** + * @generated from message ping.v1.PingStreamResponse + */ +export type PingStreamResponse = Message<"ping.v1.PingStreamResponse"> & { + /** + * @generated from field: string text = 1; + */ + text: string; +}; + +/** + * Describes the message ping.v1.PingStreamResponse. + * Use `create(PingStreamResponseSchema)` to create a new message. + */ +export const PingStreamResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_ping_v1_ping, 5); + +/** + * @generated from service ping.v1.PingService + */ +export const PingService: GenService<{ + /** + * Ping is a unary RPC that returns the same text that was sent. + * + * @generated from rpc ping.v1.PingService.Ping + */ + ping: { + methodKind: "unary"; + input: typeof PingRequestSchema; + output: typeof PingResponseSchema; + }; + /** + * Echo is a unary RPC that returns the same text that was sent. + * + * @generated from rpc ping.v1.PingService.Echo + */ + echo: { + methodKind: "unary"; + input: typeof EchoRequestSchema; + output: typeof EchoResponseSchema; + }; + /** + * PingStream is a bidirectional stream of pings. + * + * @generated from rpc ping.v1.PingService.PingStream + */ + pingStream: { + methodKind: "bidi_streaming"; + input: typeof PingStreamRequestSchema; + output: typeof PingStreamResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_ping_v1_ping, 0); diff --git a/client/src/protoFleet/api/generated/pools/v1/pools_pb.ts b/client/src/protoFleet/api/generated/pools/v1/pools_pb.ts new file mode 100644 index 000000000..17df6a423 --- /dev/null +++ b/client/src/protoFleet/api/generated/pools/v1/pools_pb.ts @@ -0,0 +1,444 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file pools/v1/pools.proto (package pools.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Duration } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_duration, file_google_protobuf_wrappers } from "@bufbuild/protobuf/wkt"; +import { file_buf_validate_validate } from "../../buf/validate/validate_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file pools/v1/pools.proto. + */ +export const file_pools_v1_pools: GenFile = + /*@__PURE__*/ + fileDesc( + "ChRwb29scy92MS9wb29scy5wcm90bxIIcG9vbHMudjEibgoKUG9vbENvbmZpZxILCgN1cmwYASABKAkSEAoIdXNlcm5hbWUYAiABKAkSLgoIcGFzc3dvcmQYAyABKAsyHC5nb29nbGUucHJvdG9idWYuU3RyaW5nVmFsdWUSEQoJcG9vbF9uYW1lGAQgASgJIkkKBFBvb2wSDwoHcG9vbF9pZBgBIAEoAxILCgN1cmwYAiABKAkSEAoIdXNlcm5hbWUYAyABKAkSEQoJcG9vbF9uYW1lGAQgASgJIhIKEExpc3RQb29sc1JlcXVlc3QiMgoRTGlzdFBvb2xzUmVzcG9uc2USHQoFcG9vbHMYASADKAsyDi5wb29scy52MS5Qb29sIj4KEUNyZWF0ZVBvb2xSZXF1ZXN0EikKC3Bvb2xfY29uZmlnGAEgASgLMhQucG9vbHMudjEuUG9vbENvbmZpZyIyChJDcmVhdGVQb29sUmVzcG9uc2USHAoEcG9vbBgBIAEoCzIOLnBvb2xzLnYxLlBvb2wihgEKEVVwZGF0ZVBvb2xSZXF1ZXN0Eg8KB3Bvb2xfaWQYASABKAMSEQoJcG9vbF9uYW1lGAIgASgJEgsKA3VybBgDIAEoCRIQCgh1c2VybmFtZRgEIAEoCRIuCghwYXNzd29yZBgFIAEoCzIcLmdvb2dsZS5wcm90b2J1Zi5TdHJpbmdWYWx1ZSIyChJVcGRhdGVQb29sUmVzcG9uc2USHAoEcG9vbBgBIAEoCzIOLnBvb2xzLnYxLlBvb2wiJAoRRGVsZXRlUG9vbFJlcXVlc3QSDwoHcG9vbF9pZBgBIAEoAyIUChJEZWxldGVQb29sUmVzcG9uc2UiwwIKE1ZhbGlkYXRlUG9vbFJlcXVlc3QSpAEKA3VybBgBIAEoCUKWAbpIkgHIAQFyjAEQDDKHAV5zdHJhdHVtXCsodGNwfHNzbHx3cyk6XC9cLygoW2EtekEtWjAtOV1bYS16QS1aMC05Li1dKlthLXpBLVowLTldXC5bYS16QS1aXXsyLH0pfChcZHsxLDN9XC4pezN9XGR7MSwzfXxcWyhbMC05YS1mQS1GOl0rKVxdKSg6XGR7MSw1fSk/JBIYCgh1c2VybmFtZRgCIAEoCUIGukgDyAEBEi4KCHBhc3N3b3JkGAMgASgLMhwuZ29vZ2xlLnByb3RvYnVmLlN0cmluZ1ZhbHVlEjsKB3RpbWVvdXQYBCABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb25CD7pIDKoBCSIDCOgCMgIIASIWChRWYWxpZGF0ZVBvb2xSZXNwb25zZSqjAQoUUG9vbENvbm5lY3Rpb25TdGF0dXMSJgoiUE9PTF9DT05ORUNUSU9OX1NUQVRVU19VTlNQRUNJRklFRBAAEh8KG1BPT0xfQ09OTkVDVElPTl9TVEFUVVNfSURMRRABEiEKHVBPT0xfQ09OTkVDVElPTl9TVEFUVVNfQUNUSVZFEAISHwobUE9PTF9DT05ORUNUSU9OX1NUQVRVU19ERUFEEAMy/gIKDFBvb2xzU2VydmljZRJECglMaXN0UG9vbHMSGi5wb29scy52MS5MaXN0UG9vbHNSZXF1ZXN0GhsucG9vbHMudjEuTGlzdFBvb2xzUmVzcG9uc2USRwoKQ3JlYXRlUG9vbBIbLnBvb2xzLnYxLkNyZWF0ZVBvb2xSZXF1ZXN0GhwucG9vbHMudjEuQ3JlYXRlUG9vbFJlc3BvbnNlEkcKClVwZGF0ZVBvb2wSGy5wb29scy52MS5VcGRhdGVQb29sUmVxdWVzdBocLnBvb2xzLnYxLlVwZGF0ZVBvb2xSZXNwb25zZRJHCgpEZWxldGVQb29sEhsucG9vbHMudjEuRGVsZXRlUG9vbFJlcXVlc3QaHC5wb29scy52MS5EZWxldGVQb29sUmVzcG9uc2USTQoMVmFsaWRhdGVQb29sEh0ucG9vbHMudjEuVmFsaWRhdGVQb29sUmVxdWVzdBoeLnBvb2xzLnYxLlZhbGlkYXRlUG9vbFJlc3BvbnNlQqABCgxjb20ucG9vbHMudjFCClBvb2xzUHJvdG9QAVpDZ2l0aHViLmNvbS9ibG9jay9wcm90by1mbGVldC9zZXJ2ZXIvZ2VuZXJhdGVkL2dycGMvcG9vbHMvdjE7cG9vbHN2MaICA1BYWKoCCFBvb2xzLlYxygIIUG9vbHNcVjHiAhRQb29sc1xWMVxHUEJNZXRhZGF0YeoCCVBvb2xzOjpWMWIGcHJvdG8z", + [file_google_protobuf_duration, file_google_protobuf_wrappers, file_buf_validate_validate], + ); + +/** + * PoolConfig defines the connection details for a mining pool + * + * @generated from message pools.v1.PoolConfig + */ +export type PoolConfig = Message<"pools.v1.PoolConfig"> & { + /** + * Pool's stratum URL (e.g., "stratum+tcp://pool.example.com:3333") + * Required field that specifies the endpoint for connecting to the pool + * + * @generated from field: string url = 1; + */ + url: string; + + /** + * Username or wallet address for pool authentication + * Required field that identifies the user/wallet receiving mining rewards + * + * @generated from field: string username = 2; + */ + username: string; + + /** + * Password for pool authentication + * May be optional depending on pool requirements, often used for worker identification + * + * @generated from field: google.protobuf.StringValue password = 3; + */ + password?: string; + + /** + * Pool name to identify this pool + * Human-readable identifier for the pool within the fleet management system + * + * @generated from field: string pool_name = 4; + */ + poolName: string; +}; + +/** + * Describes the message pools.v1.PoolConfig. + * Use `create(PoolConfigSchema)` to create a new message. + */ +export const PoolConfigSchema: GenMessage = /*@__PURE__*/ messageDesc(file_pools_v1_pools, 0); + +/** + * Pool defines a configured mining pool with its connection details and status + * + * @generated from message pools.v1.Pool + */ +export type Pool = Message<"pools.v1.Pool"> & { + /** + * Unique identifier for the pool within the system + * + * @generated from field: int64 pool_id = 1; + */ + poolId: bigint; + + /** + * Pool's stratum URL (e.g., "stratum+tcp://pool.example.com:3333") + * Endpoint for connecting to the pool + * + * @generated from field: string url = 2; + */ + url: string; + + /** + * Username or wallet address for pool authentication + * Identifies the user/wallet receiving mining rewards + * + * @generated from field: string username = 3; + */ + username: string; + + /** + * Pool name to identify this pool + * Human-readable identifier for the pool within the fleet management system + * + * @generated from field: string pool_name = 4; + */ + poolName: string; +}; + +/** + * Describes the message pools.v1.Pool. + * Use `create(PoolSchema)` to create a new message. + */ +export const PoolSchema: GenMessage = /*@__PURE__*/ messageDesc(file_pools_v1_pools, 1); + +/** + * Request to retrieve all configured mining pools + * + * Empty request as all pools are returned + * + * @generated from message pools.v1.ListPoolsRequest + */ +export type ListPoolsRequest = Message<"pools.v1.ListPoolsRequest"> & {}; + +/** + * Describes the message pools.v1.ListPoolsRequest. + * Use `create(ListPoolsRequestSchema)` to create a new message. + */ +export const ListPoolsRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_pools_v1_pools, 2); + +/** + * Response containing all configured mining pools + * + * @generated from message pools.v1.ListPoolsResponse + */ +export type ListPoolsResponse = Message<"pools.v1.ListPoolsResponse"> & { + /** + * List of all configured pools, ordered by priority + * + * @generated from field: repeated pools.v1.Pool pools = 1; + */ + pools: Pool[]; +}; + +/** + * Describes the message pools.v1.ListPoolsResponse. + * Use `create(ListPoolsResponseSchema)` to create a new message. + */ +export const ListPoolsResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_pools_v1_pools, 3); + +/** + * Request to create a new mining pool configuration + * + * @generated from message pools.v1.CreatePoolRequest + */ +export type CreatePoolRequest = Message<"pools.v1.CreatePoolRequest"> & { + /** + * Pool configuration details for the new pool + * Must contain all required connection information + * + * @generated from field: pools.v1.PoolConfig pool_config = 1; + */ + poolConfig?: PoolConfig; +}; + +/** + * Describes the message pools.v1.CreatePoolRequest. + * Use `create(CreatePoolRequestSchema)` to create a new message. + */ +export const CreatePoolRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_pools_v1_pools, 4); + +/** + * Response after creating a new mining pool + * + * @generated from message pools.v1.CreatePoolResponse + */ +export type CreatePoolResponse = Message<"pools.v1.CreatePoolResponse"> & { + /** + * The newly created pool with system-assigned ID and default values + * + * @generated from field: pools.v1.Pool pool = 1; + */ + pool?: Pool; +}; + +/** + * Describes the message pools.v1.CreatePoolResponse. + * Use `create(CreatePoolResponseSchema)` to create a new message. + */ +export const CreatePoolResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_pools_v1_pools, 5); + +/** + * Request to update an existing pool's configuration + * + * @generated from message pools.v1.UpdatePoolRequest + */ +export type UpdatePoolRequest = Message<"pools.v1.UpdatePoolRequest"> & { + /** + * Unique identifier of the pool to update + * + * @generated from field: int64 pool_id = 1; + */ + poolId: bigint; + + /** + * New pool name (optional, leave empty to keep current value) + * + * @generated from field: string pool_name = 2; + */ + poolName: string; + + /** + * New pool URL (optional, leave empty to keep current value) + * + * @generated from field: string url = 3; + */ + url: string; + + /** + * New username (optional, leave empty to keep current value) + * + * @generated from field: string username = 4; + */ + username: string; + + /** + * New password (optional, leave empty to keep current value) + * + * @generated from field: google.protobuf.StringValue password = 5; + */ + password?: string; +}; + +/** + * Describes the message pools.v1.UpdatePoolRequest. + * Use `create(UpdatePoolRequestSchema)` to create a new message. + */ +export const UpdatePoolRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_pools_v1_pools, 6); + +/** + * Response after updating a pool's configuration + * + * @generated from message pools.v1.UpdatePoolResponse + */ +export type UpdatePoolResponse = Message<"pools.v1.UpdatePoolResponse"> & { + /** + * The updated pool with all current values + * + * @generated from field: pools.v1.Pool pool = 1; + */ + pool?: Pool; +}; + +/** + * Describes the message pools.v1.UpdatePoolResponse. + * Use `create(UpdatePoolResponseSchema)` to create a new message. + */ +export const UpdatePoolResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_pools_v1_pools, 7); + +/** + * Request to delete a mining pool configuration + * + * @generated from message pools.v1.DeletePoolRequest + */ +export type DeletePoolRequest = Message<"pools.v1.DeletePoolRequest"> & { + /** + * Unique identifier of the pool to delete + * + * @generated from field: int64 pool_id = 1; + */ + poolId: bigint; +}; + +/** + * Describes the message pools.v1.DeletePoolRequest. + * Use `create(DeletePoolRequestSchema)` to create a new message. + */ +export const DeletePoolRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_pools_v1_pools, 8); + +/** + * Response after deleting a pool configuration + * + * Empty response as success/failure is indicated by gRPC status + * + * @generated from message pools.v1.DeletePoolResponse + */ +export type DeletePoolResponse = Message<"pools.v1.DeletePoolResponse"> & {}; + +/** + * Describes the message pools.v1.DeletePoolResponse. + * Use `create(DeletePoolResponseSchema)` to create a new message. + */ +export const DeletePoolResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_pools_v1_pools, 9); + +/** + * Request to validate a pool's connection details + * + * @generated from message pools.v1.ValidatePoolRequest + */ +export type ValidatePoolRequest = Message<"pools.v1.ValidatePoolRequest"> & { + /** + * Pool's stratum URL (e.g., "stratum+tcp://pool.example.com:3333") + * Required field that specifies the endpoint for connecting to the pool + * + * @generated from field: string url = 1; + */ + url: string; + + /** + * Username or wallet address for pool authentication + * Required field that identifies the user/wallet receiving mining rewards + * + * @generated from field: string username = 2; + */ + username: string; + + /** + * Password for pool authentication + * May be optional depending on pool requirements, often used for worker identification + * + * @generated from field: google.protobuf.StringValue password = 3; + */ + password?: string; + + /** + * Set the timeout duration for validation + * This is an optional field for integration points that have issues setting context timeouts. + * + * @generated from field: google.protobuf.Duration timeout = 4; + */ + timeout?: Duration; +}; + +/** + * Describes the message pools.v1.ValidatePoolRequest. + * Use `create(ValidatePoolRequestSchema)` to create a new message. + */ +export const ValidatePoolRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_pools_v1_pools, 10); + +/** + * Response after validating a pool's connection details + * + * Empty response as success/failure is indicated by gRPC status + * + * @generated from message pools.v1.ValidatePoolResponse + */ +export type ValidatePoolResponse = Message<"pools.v1.ValidatePoolResponse"> & {}; + +/** + * Describes the message pools.v1.ValidatePoolResponse. + * Use `create(ValidatePoolResponseSchema)` to create a new message. + */ +export const ValidatePoolResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_pools_v1_pools, 11); + +/** + * @generated from enum pools.v1.PoolConnectionStatus + */ +export enum PoolConnectionStatus { + /** + * @generated from enum value: POOL_CONNECTION_STATUS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: POOL_CONNECTION_STATUS_IDLE = 1; + */ + IDLE = 1, + + /** + * @generated from enum value: POOL_CONNECTION_STATUS_ACTIVE = 2; + */ + ACTIVE = 2, + + /** + * @generated from enum value: POOL_CONNECTION_STATUS_DEAD = 3; + */ + DEAD = 3, +} + +/** + * Describes the enum pools.v1.PoolConnectionStatus. + */ +export const PoolConnectionStatusSchema: GenEnum = /*@__PURE__*/ enumDesc(file_pools_v1_pools, 0); + +/** + * @generated from service pools.v1.PoolsService + */ +export const PoolsService: GenService<{ + /** + * Lists all configured mining pools + * + * @generated from rpc pools.v1.PoolsService.ListPools + */ + listPools: { + methodKind: "unary"; + input: typeof ListPoolsRequestSchema; + output: typeof ListPoolsResponseSchema; + }; + /** + * Creates a new mining pool configuration + * + * @generated from rpc pools.v1.PoolsService.CreatePool + */ + createPool: { + methodKind: "unary"; + input: typeof CreatePoolRequestSchema; + output: typeof CreatePoolResponseSchema; + }; + /** + * Updates an existing pool's configuration + * + * @generated from rpc pools.v1.PoolsService.UpdatePool + */ + updatePool: { + methodKind: "unary"; + input: typeof UpdatePoolRequestSchema; + output: typeof UpdatePoolResponseSchema; + }; + /** + * Deletes a pool configuration + * + * @generated from rpc pools.v1.PoolsService.DeletePool + */ + deletePool: { + methodKind: "unary"; + input: typeof DeletePoolRequestSchema; + output: typeof DeletePoolResponseSchema; + }; + /** + * Validates a pool's connection details + * + * @generated from rpc pools.v1.PoolsService.ValidatePool + */ + validatePool: { + methodKind: "unary"; + input: typeof ValidatePoolRequestSchema; + output: typeof ValidatePoolResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_pools_v1_pools, 0); diff --git a/client/src/protoFleet/api/generated/schedule/v1/schedule_pb.ts b/client/src/protoFleet/api/generated/schedule/v1/schedule_pb.ts new file mode 100644 index 000000000..5c693a402 --- /dev/null +++ b/client/src/protoFleet/api/generated/schedule/v1/schedule_pb.ts @@ -0,0 +1,942 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file schedule/v1/schedule.proto (package schedule.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import { file_buf_validate_validate } from "../../buf/validate/validate_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file schedule/v1/schedule.proto. + */ +export const file_schedule_v1_schedule: GenFile = + /*@__PURE__*/ + fileDesc( + "ChpzY2hlZHVsZS92MS9zY2hlZHVsZS5wcm90bxILc2NoZWR1bGUudjEiPwoRUG93ZXJUYXJnZXRDb25maWcSKgoEbW9kZRgBIAEoDjIcLnNjaGVkdWxlLnYxLlBvd2VyVGFyZ2V0TW9kZSLJAQoSU2NoZWR1bGVSZWN1cnJlbmNlEjMKCWZyZXF1ZW5jeRgBIAEoDjIgLnNjaGVkdWxlLnYxLlJlY3VycmVuY2VGcmVxdWVuY3kSGQoIaW50ZXJ2YWwYAiABKAVCB7pIBBoCCAESLAoMZGF5c19vZl93ZWVrGAMgAygOMhYuc2NoZWR1bGUudjEuRGF5T2ZXZWVrEiQKDGRheV9vZl9tb250aBgEIAEoBUIJukgGGgQYHygBSACIAQFCDwoNX2RheV9vZl9tb250aCJuCg5TY2hlZHVsZVRhcmdldBJACgt0YXJnZXRfdHlwZRgBIAEoDjIfLnNjaGVkdWxlLnYxLlNjaGVkdWxlVGFyZ2V0VHlwZUIKukgHggEEEAEgABIaCgl0YXJnZXRfaWQYAiABKAlCB7pIBHICEAEixQUKCFNjaGVkdWxlEgoKAmlkGAEgASgDEgwKBG5hbWUYAiABKAkSKwoGYWN0aW9uGAMgASgOMhsuc2NoZWR1bGUudjEuU2NoZWR1bGVBY3Rpb24SNQoNYWN0aW9uX2NvbmZpZxgEIAEoCzIeLnNjaGVkdWxlLnYxLlBvd2VyVGFyZ2V0Q29uZmlnEjAKDXNjaGVkdWxlX3R5cGUYBSABKA4yGS5zY2hlZHVsZS52MS5TY2hlZHVsZVR5cGUSMwoKcmVjdXJyZW5jZRgGIAEoCzIfLnNjaGVkdWxlLnYxLlNjaGVkdWxlUmVjdXJyZW5jZRISCgpzdGFydF9kYXRlGAcgASgJEhIKCnN0YXJ0X3RpbWUYCCABKAkSEAoIZW5kX3RpbWUYCSABKAkSEAoIZW5kX2RhdGUYCyABKAkSEAoIdGltZXpvbmUYDSABKAkSKwoGc3RhdHVzGA4gASgOMhsuc2NoZWR1bGUudjEuU2NoZWR1bGVTdGF0dXMSEAoIcHJpb3JpdHkYDyABKAUSEgoKY3JlYXRlZF9ieRgRIAEoAxIuCgpjcmVhdGVkX2F0GBIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgp1cGRhdGVkX2F0GBMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIvCgtsYXN0X3J1bl9hdBgUIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLwoLbmV4dF9ydW5fYXQYFSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEiwKB3RhcmdldHMYFyADKAsyGy5zY2hlZHVsZS52MS5TY2hlZHVsZVRhcmdldBIbChNjcmVhdGVkX2J5X3VzZXJuYW1lGBggASgJSgQIChALSgQIDBANSgQIEBARSgQIFhAXIoQBChRMaXN0U2NoZWR1bGVzUmVxdWVzdBI1CgZzdGF0dXMYASABKA4yGy5zY2hlZHVsZS52MS5TY2hlZHVsZVN0YXR1c0IIukgFggECEAESNQoGYWN0aW9uGAIgASgOMhsuc2NoZWR1bGUudjEuU2NoZWR1bGVBY3Rpb25CCLpIBYIBAhABIkEKFUxpc3RTY2hlZHVsZXNSZXNwb25zZRIoCglzY2hlZHVsZXMYASADKAsyFS5zY2hlZHVsZS52MS5TY2hlZHVsZSLaBAoVQ3JlYXRlU2NoZWR1bGVSZXF1ZXN0EhoKBG5hbWUYASABKAlCDLpICcgBAXIEEAEYZBI3CgZhY3Rpb24YAiABKA4yGy5zY2hlZHVsZS52MS5TY2hlZHVsZUFjdGlvbkIKukgHggEEEAEgABI1Cg1hY3Rpb25fY29uZmlnGAMgASgLMh4uc2NoZWR1bGUudjEuUG93ZXJUYXJnZXRDb25maWcSPAoNc2NoZWR1bGVfdHlwZRgEIAEoDjIZLnNjaGVkdWxlLnYxLlNjaGVkdWxlVHlwZUIKukgHggEEEAEgABIzCgpyZWN1cnJlbmNlGAUgASgLMh8uc2NoZWR1bGUudjEuU2NoZWR1bGVSZWN1cnJlbmNlEj0KCnN0YXJ0X2RhdGUYBiABKAlCKbpIJsgBAXIhMhxeWzAtOV17NH0tWzAtOV17Mn0tWzAtOV17Mn0kmAEKEjQKCnN0YXJ0X3RpbWUYByABKAlCILpIHcgBAXIYMhNeWzAtOV17Mn06WzAtOV17Mn0kmAEFEjIKCGVuZF90aW1lGAggASgJQiC6SB3YAQFyGDITXlswLTldezJ9OlswLTldezJ9JJgBBRI7CghlbmRfZGF0ZRgKIAEoCUIpukgm2AEBciEyHF5bMC05XXs0fS1bMC05XXsyfS1bMC05XXsyfSSYAQoSHAoIdGltZXpvbmUYDCABKAlCCrpIB8gBAXICEAESLAoHdGFyZ2V0cxgOIAMoCzIbLnNjaGVkdWxlLnYxLlNjaGVkdWxlVGFyZ2V0SgQICRAKSgQICxAMSgQIDRAOIkEKFkNyZWF0ZVNjaGVkdWxlUmVzcG9uc2USJwoIc2NoZWR1bGUYASABKAsyFS5zY2hlZHVsZS52MS5TY2hlZHVsZSL4BAoVVXBkYXRlU2NoZWR1bGVSZXF1ZXN0EhwKC3NjaGVkdWxlX2lkGAEgASgDQge6SAQiAiAAEhoKBG5hbWUYAiABKAlCDLpICcgBAXIEEAEYZBI3CgZhY3Rpb24YAyABKA4yGy5zY2hlZHVsZS52MS5TY2hlZHVsZUFjdGlvbkIKukgHggEEEAEgABI1Cg1hY3Rpb25fY29uZmlnGAQgASgLMh4uc2NoZWR1bGUudjEuUG93ZXJUYXJnZXRDb25maWcSPAoNc2NoZWR1bGVfdHlwZRgFIAEoDjIZLnNjaGVkdWxlLnYxLlNjaGVkdWxlVHlwZUIKukgHggEEEAEgABIzCgpyZWN1cnJlbmNlGAYgASgLMh8uc2NoZWR1bGUudjEuU2NoZWR1bGVSZWN1cnJlbmNlEj0KCnN0YXJ0X2RhdGUYByABKAlCKbpIJsgBAXIhMhxeWzAtOV17NH0tWzAtOV17Mn0tWzAtOV17Mn0kmAEKEjQKCnN0YXJ0X3RpbWUYCCABKAlCILpIHcgBAXIYMhNeWzAtOV17Mn06WzAtOV17Mn0kmAEFEjIKCGVuZF90aW1lGAkgASgJQiC6SB3YAQFyGDITXlswLTldezJ9OlswLTldezJ9JJgBBRI7CghlbmRfZGF0ZRgLIAEoCUIpukgm2AEBciEyHF5bMC05XXs0fS1bMC05XXsyfS1bMC05XXsyfSSYAQoSHAoIdGltZXpvbmUYDSABKAlCCrpIB8gBAXICEAESLAoHdGFyZ2V0cxgPIAMoCzIbLnNjaGVkdWxlLnYxLlNjaGVkdWxlVGFyZ2V0SgQIChALSgQIDBANSgQIDhAPIkEKFlVwZGF0ZVNjaGVkdWxlUmVzcG9uc2USJwoIc2NoZWR1bGUYASABKAsyFS5zY2hlZHVsZS52MS5TY2hlZHVsZSI1ChVEZWxldGVTY2hlZHVsZVJlcXVlc3QSHAoLc2NoZWR1bGVfaWQYASABKANCB7pIBCICIAAiGAoWRGVsZXRlU2NoZWR1bGVSZXNwb25zZSI0ChRQYXVzZVNjaGVkdWxlUmVxdWVzdBIcCgtzY2hlZHVsZV9pZBgBIAEoA0IHukgEIgIgACJAChVQYXVzZVNjaGVkdWxlUmVzcG9uc2USJwoIc2NoZWR1bGUYASABKAsyFS5zY2hlZHVsZS52MS5TY2hlZHVsZSI1ChVSZXN1bWVTY2hlZHVsZVJlcXVlc3QSHAoLc2NoZWR1bGVfaWQYASABKANCB7pIBCICIAAiQQoWUmVzdW1lU2NoZWR1bGVSZXNwb25zZRInCghzY2hlZHVsZRgBIAEoCzIVLnNjaGVkdWxlLnYxLlNjaGVkdWxlIkEKF1Jlb3JkZXJTY2hlZHVsZXNSZXF1ZXN0EiYKDHNjaGVkdWxlX2lkcxgBIAMoA0IQukgNkgEKCAEYASIEIgIgACIaChhSZW9yZGVyU2NoZWR1bGVzUmVzcG9uc2UqpQEKDlNjaGVkdWxlU3RhdHVzEh8KG1NDSEVEVUxFX1NUQVRVU19VTlNQRUNJRklFRBAAEhoKFlNDSEVEVUxFX1NUQVRVU19BQ1RJVkUQARIaChZTQ0hFRFVMRV9TVEFUVVNfUEFVU0VEEAISGwoXU0NIRURVTEVfU1RBVFVTX1JVTk5JTkcQAxIdChlTQ0hFRFVMRV9TVEFUVVNfQ09NUExFVEVEEAQqjgEKDlNjaGVkdWxlQWN0aW9uEh8KG1NDSEVEVUxFX0FDVElPTl9VTlNQRUNJRklFRBAAEiQKIFNDSEVEVUxFX0FDVElPTl9TRVRfUE9XRVJfVEFSR0VUEAESGgoWU0NIRURVTEVfQUNUSU9OX1JFQk9PVBACEhkKFVNDSEVEVUxFX0FDVElPTl9TTEVFUBADKmYKDFNjaGVkdWxlVHlwZRIdChlTQ0hFRFVMRV9UWVBFX1VOU1BFQ0lGSUVEEAASGgoWU0NIRURVTEVfVFlQRV9PTkVfVElNRRABEhsKF1NDSEVEVUxFX1RZUEVfUkVDVVJSSU5HEAIqngEKE1JlY3VycmVuY2VGcmVxdWVuY3kSJAogUkVDVVJSRU5DRV9GUkVRVUVOQ1lfVU5TUEVDSUZJRUQQABIeChpSRUNVUlJFTkNFX0ZSRVFVRU5DWV9EQUlMWRABEh8KG1JFQ1VSUkVOQ0VfRlJFUVVFTkNZX1dFRUtMWRACEiAKHFJFQ1VSUkVOQ0VfRlJFUVVFTkNZX01PTlRITFkQAypuCg9Qb3dlclRhcmdldE1vZGUSIQodUE9XRVJfVEFSR0VUX01PREVfVU5TUEVDSUZJRUQQABIdChlQT1dFUl9UQVJHRVRfTU9ERV9ERUZBVUxUEAESGQoVUE9XRVJfVEFSR0VUX01PREVfTUFYEAIq2AEKCURheU9mV2VlaxIbChdEQVlfT0ZfV0VFS19VTlNQRUNJRklFRBAAEhYKEkRBWV9PRl9XRUVLX1NVTkRBWRABEhYKEkRBWV9PRl9XRUVLX01PTkRBWRACEhcKE0RBWV9PRl9XRUVLX1RVRVNEQVkQAxIZChVEQVlfT0ZfV0VFS19XRURORVNEQVkQBBIYChREQVlfT0ZfV0VFS19USFVSU0RBWRAFEhYKEkRBWV9PRl9XRUVLX0ZSSURBWRAGEhgKFERBWV9PRl9XRUVLX1NBVFVSREFZEAcqmQEKElNjaGVkdWxlVGFyZ2V0VHlwZRIkCiBTQ0hFRFVMRV9UQVJHRVRfVFlQRV9VTlNQRUNJRklFRBAAEh0KGVNDSEVEVUxFX1RBUkdFVF9UWVBFX1JBQ0sQARIeChpTQ0hFRFVMRV9UQVJHRVRfVFlQRV9NSU5FUhACEh4KGlNDSEVEVUxFX1RBUkdFVF9UWVBFX0dST1VQEAMyjgUKD1NjaGVkdWxlU2VydmljZRJWCg1MaXN0U2NoZWR1bGVzEiEuc2NoZWR1bGUudjEuTGlzdFNjaGVkdWxlc1JlcXVlc3QaIi5zY2hlZHVsZS52MS5MaXN0U2NoZWR1bGVzUmVzcG9uc2USWQoOQ3JlYXRlU2NoZWR1bGUSIi5zY2hlZHVsZS52MS5DcmVhdGVTY2hlZHVsZVJlcXVlc3QaIy5zY2hlZHVsZS52MS5DcmVhdGVTY2hlZHVsZVJlc3BvbnNlElkKDlVwZGF0ZVNjaGVkdWxlEiIuc2NoZWR1bGUudjEuVXBkYXRlU2NoZWR1bGVSZXF1ZXN0GiMuc2NoZWR1bGUudjEuVXBkYXRlU2NoZWR1bGVSZXNwb25zZRJZCg5EZWxldGVTY2hlZHVsZRIiLnNjaGVkdWxlLnYxLkRlbGV0ZVNjaGVkdWxlUmVxdWVzdBojLnNjaGVkdWxlLnYxLkRlbGV0ZVNjaGVkdWxlUmVzcG9uc2USVgoNUGF1c2VTY2hlZHVsZRIhLnNjaGVkdWxlLnYxLlBhdXNlU2NoZWR1bGVSZXF1ZXN0GiIuc2NoZWR1bGUudjEuUGF1c2VTY2hlZHVsZVJlc3BvbnNlElkKDlJlc3VtZVNjaGVkdWxlEiIuc2NoZWR1bGUudjEuUmVzdW1lU2NoZWR1bGVSZXF1ZXN0GiMuc2NoZWR1bGUudjEuUmVzdW1lU2NoZWR1bGVSZXNwb25zZRJfChBSZW9yZGVyU2NoZWR1bGVzEiQuc2NoZWR1bGUudjEuUmVvcmRlclNjaGVkdWxlc1JlcXVlc3QaJS5zY2hlZHVsZS52MS5SZW9yZGVyU2NoZWR1bGVzUmVzcG9uc2VCuAEKD2NvbS5zY2hlZHVsZS52MUINU2NoZWR1bGVQcm90b1ABWklnaXRodWIuY29tL2Jsb2NrL3Byb3RvLWZsZWV0L3NlcnZlci9nZW5lcmF0ZWQvZ3JwYy9zY2hlZHVsZS92MTtzY2hlZHVsZXYxogIDU1hYqgILU2NoZWR1bGUuVjHKAgtTY2hlZHVsZVxWMeICF1NjaGVkdWxlXFYxXEdQQk1ldGFkYXRh6gIMU2NoZWR1bGU6OlYxYgZwcm90bzM", + [file_google_protobuf_timestamp, file_buf_validate_validate], + ); + +/** + * Configuration for the power target action + * + * @generated from message schedule.v1.PowerTargetConfig + */ +export type PowerTargetConfig = Message<"schedule.v1.PowerTargetConfig"> & { + /** + * @generated from field: schedule.v1.PowerTargetMode mode = 1; + */ + mode: PowerTargetMode; +}; + +/** + * Describes the message schedule.v1.PowerTargetConfig. + * Use `create(PowerTargetConfigSchema)` to create a new message. + */ +export const PowerTargetConfigSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 0); + +/** + * Recurrence configuration for recurring schedules + * + * @generated from message schedule.v1.ScheduleRecurrence + */ +export type ScheduleRecurrence = Message<"schedule.v1.ScheduleRecurrence"> & { + /** + * How often the schedule repeats + * + * @generated from field: schedule.v1.RecurrenceFrequency frequency = 1; + */ + frequency: RecurrenceFrequency; + + /** + * Repeat interval. Must be 1. + * + * @generated from field: int32 interval = 2; + */ + interval: number; + + /** + * Days of the week for weekly recurrence (at least one required when frequency is WEEKLY) + * + * @generated from field: repeated schedule.v1.DayOfWeek days_of_week = 3; + */ + daysOfWeek: DayOfWeek[]; + + /** + * Day of month for monthly recurrence (1-31). Months without this day are + * skipped (e.g., day 31 skips Feb, Apr, Jun, Sep, Nov). + * + * @generated from field: optional int32 day_of_month = 4; + */ + dayOfMonth?: number; +}; + +/** + * Describes the message schedule.v1.ScheduleRecurrence. + * Use `create(ScheduleRecurrenceSchema)` to create a new message. + */ +export const ScheduleRecurrenceSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 1); + +/** + * Target for a schedule (rack, group, or individual miner) + * + * @generated from message schedule.v1.ScheduleTarget + */ +export type ScheduleTarget = Message<"schedule.v1.ScheduleTarget"> & { + /** + * Type of target: rack, group, or miner + * + * @generated from field: schedule.v1.ScheduleTargetType target_type = 1; + */ + targetType: ScheduleTargetType; + + /** + * Identifier for the target (rack ID, group ID, or miner device identifier) + * + * @generated from field: string target_id = 2; + */ + targetId: string; +}; + +/** + * Describes the message schedule.v1.ScheduleTarget. + * Use `create(ScheduleTargetSchema)` to create a new message. + */ +export const ScheduleTargetSchema: GenMessage = /*@__PURE__*/ messageDesc(file_schedule_v1_schedule, 2); + +/** + * Full schedule entity + * + * @generated from message schedule.v1.Schedule + */ +export type Schedule = Message<"schedule.v1.Schedule"> & { + /** + * @generated from field: int64 id = 1; + */ + id: bigint; + + /** + * @generated from field: string name = 2; + */ + name: string; + + /** + * @generated from field: schedule.v1.ScheduleAction action = 3; + */ + action: ScheduleAction; + + /** + * @generated from field: schedule.v1.PowerTargetConfig action_config = 4; + */ + actionConfig?: PowerTargetConfig; + + /** + * @generated from field: schedule.v1.ScheduleType schedule_type = 5; + */ + scheduleType: ScheduleType; + + /** + * @generated from field: schedule.v1.ScheduleRecurrence recurrence = 6; + */ + recurrence?: ScheduleRecurrence; + + /** + * @generated from field: string start_date = 7; + */ + startDate: string; + + /** + * @generated from field: string start_time = 8; + */ + startTime: string; + + /** + * @generated from field: string end_time = 9; + */ + endTime: string; + + /** + * @generated from field: string end_date = 11; + */ + endDate: string; + + /** + * @generated from field: string timezone = 13; + */ + timezone: string; + + /** + * @generated from field: schedule.v1.ScheduleStatus status = 14; + */ + status: ScheduleStatus; + + /** + * @generated from field: int32 priority = 15; + */ + priority: number; + + /** + * @generated from field: int64 created_by = 17; + */ + createdBy: bigint; + + /** + * @generated from field: google.protobuf.Timestamp created_at = 18; + */ + createdAt?: Timestamp; + + /** + * @generated from field: google.protobuf.Timestamp updated_at = 19; + */ + updatedAt?: Timestamp; + + /** + * @generated from field: google.protobuf.Timestamp last_run_at = 20; + */ + lastRunAt?: Timestamp; + + /** + * @generated from field: google.protobuf.Timestamp next_run_at = 21; + */ + nextRunAt?: Timestamp; + + /** + * @generated from field: repeated schedule.v1.ScheduleTarget targets = 23; + */ + targets: ScheduleTarget[]; + + /** + * @generated from field: string created_by_username = 24; + */ + createdByUsername: string; +}; + +/** + * Describes the message schedule.v1.Schedule. + * Use `create(ScheduleSchema)` to create a new message. + */ +export const ScheduleSchema: GenMessage = /*@__PURE__*/ messageDesc(file_schedule_v1_schedule, 3); + +/** + * @generated from message schedule.v1.ListSchedulesRequest + */ +export type ListSchedulesRequest = Message<"schedule.v1.ListSchedulesRequest"> & { + /** + * Filter by status (optional, returns all statuses if unspecified) + * + * @generated from field: schedule.v1.ScheduleStatus status = 1; + */ + status: ScheduleStatus; + + /** + * Filter by action type (optional, returns all actions if unspecified) + * + * @generated from field: schedule.v1.ScheduleAction action = 2; + */ + action: ScheduleAction; +}; + +/** + * Describes the message schedule.v1.ListSchedulesRequest. + * Use `create(ListSchedulesRequestSchema)` to create a new message. + */ +export const ListSchedulesRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 4); + +/** + * @generated from message schedule.v1.ListSchedulesResponse + */ +export type ListSchedulesResponse = Message<"schedule.v1.ListSchedulesResponse"> & { + /** + * @generated from field: repeated schedule.v1.Schedule schedules = 1; + */ + schedules: Schedule[]; +}; + +/** + * Describes the message schedule.v1.ListSchedulesResponse. + * Use `create(ListSchedulesResponseSchema)` to create a new message. + */ +export const ListSchedulesResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 5); + +/** + * @generated from message schedule.v1.CreateScheduleRequest + */ +export type CreateScheduleRequest = Message<"schedule.v1.CreateScheduleRequest"> & { + /** + * Schedule name (required, 1-100 characters) + * + * @generated from field: string name = 1; + */ + name: string; + + /** + * Action to perform (required) + * + * @generated from field: schedule.v1.ScheduleAction action = 2; + */ + action: ScheduleAction; + + /** + * Power target configuration (required when action is SET_POWER_TARGET) + * + * @generated from field: schedule.v1.PowerTargetConfig action_config = 3; + */ + actionConfig?: PowerTargetConfig; + + /** + * Schedule type (required) + * + * @generated from field: schedule.v1.ScheduleType schedule_type = 4; + */ + scheduleType: ScheduleType; + + /** + * Recurrence configuration (required when schedule_type is RECURRING) + * + * @generated from field: schedule.v1.ScheduleRecurrence recurrence = 5; + */ + recurrence?: ScheduleRecurrence; + + /** + * Start date in YYYY-MM-DD format (required) + * + * @generated from field: string start_date = 6; + */ + startDate: string; + + /** + * Start time in HH:MM format (required) + * + * @generated from field: string start_time = 7; + */ + startTime: string; + + /** + * End time in HH:MM format (for power target time windows) + * + * @generated from field: string end_time = 8; + */ + endTime: string; + + /** + * End date in YYYY-MM-DD format (if set, schedule ends on this date; if absent, runs indefinitely) + * + * @generated from field: string end_date = 10; + */ + endDate: string; + + /** + * IANA timezone string (required, e.g. "America/Chicago") + * + * @generated from field: string timezone = 12; + */ + timezone: string; + + /** + * Targets for this schedule (racks and/or miners) + * + * @generated from field: repeated schedule.v1.ScheduleTarget targets = 14; + */ + targets: ScheduleTarget[]; +}; + +/** + * Describes the message schedule.v1.CreateScheduleRequest. + * Use `create(CreateScheduleRequestSchema)` to create a new message. + */ +export const CreateScheduleRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 6); + +/** + * @generated from message schedule.v1.CreateScheduleResponse + */ +export type CreateScheduleResponse = Message<"schedule.v1.CreateScheduleResponse"> & { + /** + * @generated from field: schedule.v1.Schedule schedule = 1; + */ + schedule?: Schedule; +}; + +/** + * Describes the message schedule.v1.CreateScheduleResponse. + * Use `create(CreateScheduleResponseSchema)` to create a new message. + */ +export const CreateScheduleResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 7); + +/** + * @generated from message schedule.v1.UpdateScheduleRequest + */ +export type UpdateScheduleRequest = Message<"schedule.v1.UpdateScheduleRequest"> & { + /** + * ID of the schedule to update (required) + * + * @generated from field: int64 schedule_id = 1; + */ + scheduleId: bigint; + + /** + * Schedule name (required, 1-100 characters) + * + * @generated from field: string name = 2; + */ + name: string; + + /** + * Action to perform (required) + * + * @generated from field: schedule.v1.ScheduleAction action = 3; + */ + action: ScheduleAction; + + /** + * Power target configuration (required when action is SET_POWER_TARGET) + * + * @generated from field: schedule.v1.PowerTargetConfig action_config = 4; + */ + actionConfig?: PowerTargetConfig; + + /** + * Schedule type (required) + * + * @generated from field: schedule.v1.ScheduleType schedule_type = 5; + */ + scheduleType: ScheduleType; + + /** + * Recurrence configuration (required when schedule_type is RECURRING) + * + * @generated from field: schedule.v1.ScheduleRecurrence recurrence = 6; + */ + recurrence?: ScheduleRecurrence; + + /** + * Start date in YYYY-MM-DD format (required) + * + * @generated from field: string start_date = 7; + */ + startDate: string; + + /** + * Start time in HH:MM format (required) + * + * @generated from field: string start_time = 8; + */ + startTime: string; + + /** + * End time in HH:MM format (for power target time windows) + * + * @generated from field: string end_time = 9; + */ + endTime: string; + + /** + * End date in YYYY-MM-DD format (if set, schedule ends on this date; if absent, runs indefinitely) + * + * @generated from field: string end_date = 11; + */ + endDate: string; + + /** + * IANA timezone string (required, e.g. "America/Chicago") + * + * @generated from field: string timezone = 13; + */ + timezone: string; + + /** + * Updated targets (replaces all existing targets) + * + * @generated from field: repeated schedule.v1.ScheduleTarget targets = 15; + */ + targets: ScheduleTarget[]; +}; + +/** + * Describes the message schedule.v1.UpdateScheduleRequest. + * Use `create(UpdateScheduleRequestSchema)` to create a new message. + */ +export const UpdateScheduleRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 8); + +/** + * @generated from message schedule.v1.UpdateScheduleResponse + */ +export type UpdateScheduleResponse = Message<"schedule.v1.UpdateScheduleResponse"> & { + /** + * @generated from field: schedule.v1.Schedule schedule = 1; + */ + schedule?: Schedule; +}; + +/** + * Describes the message schedule.v1.UpdateScheduleResponse. + * Use `create(UpdateScheduleResponseSchema)` to create a new message. + */ +export const UpdateScheduleResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 9); + +/** + * @generated from message schedule.v1.DeleteScheduleRequest + */ +export type DeleteScheduleRequest = Message<"schedule.v1.DeleteScheduleRequest"> & { + /** + * @generated from field: int64 schedule_id = 1; + */ + scheduleId: bigint; +}; + +/** + * Describes the message schedule.v1.DeleteScheduleRequest. + * Use `create(DeleteScheduleRequestSchema)` to create a new message. + */ +export const DeleteScheduleRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 10); + +/** + * @generated from message schedule.v1.DeleteScheduleResponse + */ +export type DeleteScheduleResponse = Message<"schedule.v1.DeleteScheduleResponse"> & {}; + +/** + * Describes the message schedule.v1.DeleteScheduleResponse. + * Use `create(DeleteScheduleResponseSchema)` to create a new message. + */ +export const DeleteScheduleResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 11); + +/** + * @generated from message schedule.v1.PauseScheduleRequest + */ +export type PauseScheduleRequest = Message<"schedule.v1.PauseScheduleRequest"> & { + /** + * @generated from field: int64 schedule_id = 1; + */ + scheduleId: bigint; +}; + +/** + * Describes the message schedule.v1.PauseScheduleRequest. + * Use `create(PauseScheduleRequestSchema)` to create a new message. + */ +export const PauseScheduleRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 12); + +/** + * @generated from message schedule.v1.PauseScheduleResponse + */ +export type PauseScheduleResponse = Message<"schedule.v1.PauseScheduleResponse"> & { + /** + * @generated from field: schedule.v1.Schedule schedule = 1; + */ + schedule?: Schedule; +}; + +/** + * Describes the message schedule.v1.PauseScheduleResponse. + * Use `create(PauseScheduleResponseSchema)` to create a new message. + */ +export const PauseScheduleResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 13); + +/** + * @generated from message schedule.v1.ResumeScheduleRequest + */ +export type ResumeScheduleRequest = Message<"schedule.v1.ResumeScheduleRequest"> & { + /** + * @generated from field: int64 schedule_id = 1; + */ + scheduleId: bigint; +}; + +/** + * Describes the message schedule.v1.ResumeScheduleRequest. + * Use `create(ResumeScheduleRequestSchema)` to create a new message. + */ +export const ResumeScheduleRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 14); + +/** + * @generated from message schedule.v1.ResumeScheduleResponse + */ +export type ResumeScheduleResponse = Message<"schedule.v1.ResumeScheduleResponse"> & { + /** + * @generated from field: schedule.v1.Schedule schedule = 1; + */ + schedule?: Schedule; +}; + +/** + * Describes the message schedule.v1.ResumeScheduleResponse. + * Use `create(ResumeScheduleResponseSchema)` to create a new message. + */ +export const ResumeScheduleResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 15); + +/** + * @generated from message schedule.v1.ReorderSchedulesRequest + */ +export type ReorderSchedulesRequest = Message<"schedule.v1.ReorderSchedulesRequest"> & { + /** + * Ordered list of schedule IDs. Position in list determines new priority (index 0 = highest). + * + * @generated from field: repeated int64 schedule_ids = 1; + */ + scheduleIds: bigint[]; +}; + +/** + * Describes the message schedule.v1.ReorderSchedulesRequest. + * Use `create(ReorderSchedulesRequestSchema)` to create a new message. + */ +export const ReorderSchedulesRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 16); + +/** + * @generated from message schedule.v1.ReorderSchedulesResponse + */ +export type ReorderSchedulesResponse = Message<"schedule.v1.ReorderSchedulesResponse"> & {}; + +/** + * Describes the message schedule.v1.ReorderSchedulesResponse. + * Use `create(ReorderSchedulesResponseSchema)` to create a new message. + */ +export const ReorderSchedulesResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 17); + +/** + * @generated from enum schedule.v1.ScheduleStatus + */ +export enum ScheduleStatus { + /** + * @generated from enum value: SCHEDULE_STATUS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: SCHEDULE_STATUS_ACTIVE = 1; + */ + ACTIVE = 1, + + /** + * @generated from enum value: SCHEDULE_STATUS_PAUSED = 2; + */ + PAUSED = 2, + + /** + * @generated from enum value: SCHEDULE_STATUS_RUNNING = 3; + */ + RUNNING = 3, + + /** + * @generated from enum value: SCHEDULE_STATUS_COMPLETED = 4; + */ + COMPLETED = 4, +} + +/** + * Describes the enum schedule.v1.ScheduleStatus. + */ +export const ScheduleStatusSchema: GenEnum = /*@__PURE__*/ enumDesc(file_schedule_v1_schedule, 0); + +/** + * @generated from enum schedule.v1.ScheduleAction + */ +export enum ScheduleAction { + /** + * @generated from enum value: SCHEDULE_ACTION_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: SCHEDULE_ACTION_SET_POWER_TARGET = 1; + */ + SET_POWER_TARGET = 1, + + /** + * @generated from enum value: SCHEDULE_ACTION_REBOOT = 2; + */ + REBOOT = 2, + + /** + * @generated from enum value: SCHEDULE_ACTION_SLEEP = 3; + */ + SLEEP = 3, +} + +/** + * Describes the enum schedule.v1.ScheduleAction. + */ +export const ScheduleActionSchema: GenEnum = /*@__PURE__*/ enumDesc(file_schedule_v1_schedule, 1); + +/** + * @generated from enum schedule.v1.ScheduleType + */ +export enum ScheduleType { + /** + * @generated from enum value: SCHEDULE_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: SCHEDULE_TYPE_ONE_TIME = 1; + */ + ONE_TIME = 1, + + /** + * @generated from enum value: SCHEDULE_TYPE_RECURRING = 2; + */ + RECURRING = 2, +} + +/** + * Describes the enum schedule.v1.ScheduleType. + */ +export const ScheduleTypeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_schedule_v1_schedule, 2); + +/** + * @generated from enum schedule.v1.RecurrenceFrequency + */ +export enum RecurrenceFrequency { + /** + * @generated from enum value: RECURRENCE_FREQUENCY_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: RECURRENCE_FREQUENCY_DAILY = 1; + */ + DAILY = 1, + + /** + * @generated from enum value: RECURRENCE_FREQUENCY_WEEKLY = 2; + */ + WEEKLY = 2, + + /** + * @generated from enum value: RECURRENCE_FREQUENCY_MONTHLY = 3; + */ + MONTHLY = 3, +} + +/** + * Describes the enum schedule.v1.RecurrenceFrequency. + */ +export const RecurrenceFrequencySchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_schedule_v1_schedule, 3); + +/** + * Power target mode for set_power_target action. Custom kW deferred to v1.1. + * + * @generated from enum schedule.v1.PowerTargetMode + */ +export enum PowerTargetMode { + /** + * @generated from enum value: POWER_TARGET_MODE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: POWER_TARGET_MODE_DEFAULT = 1; + */ + DEFAULT = 1, + + /** + * @generated from enum value: POWER_TARGET_MODE_MAX = 2; + */ + MAX = 2, +} + +/** + * Describes the enum schedule.v1.PowerTargetMode. + */ +export const PowerTargetModeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_schedule_v1_schedule, 4); + +/** + * Day of week for weekly recurrence + * + * @generated from enum schedule.v1.DayOfWeek + */ +export enum DayOfWeek { + /** + * @generated from enum value: DAY_OF_WEEK_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: DAY_OF_WEEK_SUNDAY = 1; + */ + SUNDAY = 1, + + /** + * @generated from enum value: DAY_OF_WEEK_MONDAY = 2; + */ + MONDAY = 2, + + /** + * @generated from enum value: DAY_OF_WEEK_TUESDAY = 3; + */ + TUESDAY = 3, + + /** + * @generated from enum value: DAY_OF_WEEK_WEDNESDAY = 4; + */ + WEDNESDAY = 4, + + /** + * @generated from enum value: DAY_OF_WEEK_THURSDAY = 5; + */ + THURSDAY = 5, + + /** + * @generated from enum value: DAY_OF_WEEK_FRIDAY = 6; + */ + FRIDAY = 6, + + /** + * @generated from enum value: DAY_OF_WEEK_SATURDAY = 7; + */ + SATURDAY = 7, +} + +/** + * Describes the enum schedule.v1.DayOfWeek. + */ +export const DayOfWeekSchema: GenEnum = /*@__PURE__*/ enumDesc(file_schedule_v1_schedule, 5); + +/** + * Target type for a schedule + * + * @generated from enum schedule.v1.ScheduleTargetType + */ +export enum ScheduleTargetType { + /** + * @generated from enum value: SCHEDULE_TARGET_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: SCHEDULE_TARGET_TYPE_RACK = 1; + */ + RACK = 1, + + /** + * @generated from enum value: SCHEDULE_TARGET_TYPE_MINER = 2; + */ + MINER = 2, + + /** + * @generated from enum value: SCHEDULE_TARGET_TYPE_GROUP = 3; + */ + GROUP = 3, +} + +/** + * Describes the enum schedule.v1.ScheduleTargetType. + */ +export const ScheduleTargetTypeSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_schedule_v1_schedule, 6); + +/** + * Service for managing scheduled miner operations (power target changes, reboots, and sleep) + * with priority-based conflict resolution. + * + * @generated from service schedule.v1.ScheduleService + */ +export const ScheduleService: GenService<{ + /** + * Lists all schedules for the organization, ordered by priority + * + * @generated from rpc schedule.v1.ScheduleService.ListSchedules + */ + listSchedules: { + methodKind: "unary"; + input: typeof ListSchedulesRequestSchema; + output: typeof ListSchedulesResponseSchema; + }; + /** + * Creates a new schedule + * + * @generated from rpc schedule.v1.ScheduleService.CreateSchedule + */ + createSchedule: { + methodKind: "unary"; + input: typeof CreateScheduleRequestSchema; + output: typeof CreateScheduleResponseSchema; + }; + /** + * Updates an existing schedule + * + * @generated from rpc schedule.v1.ScheduleService.UpdateSchedule + */ + updateSchedule: { + methodKind: "unary"; + input: typeof UpdateScheduleRequestSchema; + output: typeof UpdateScheduleResponseSchema; + }; + /** + * Soft-deletes a schedule + * + * @generated from rpc schedule.v1.ScheduleService.DeleteSchedule + */ + deleteSchedule: { + methodKind: "unary"; + input: typeof DeleteScheduleRequestSchema; + output: typeof DeleteScheduleResponseSchema; + }; + /** + * Pauses an active schedule + * + * @generated from rpc schedule.v1.ScheduleService.PauseSchedule + */ + pauseSchedule: { + methodKind: "unary"; + input: typeof PauseScheduleRequestSchema; + output: typeof PauseScheduleResponseSchema; + }; + /** + * Resumes a paused schedule + * + * @generated from rpc schedule.v1.ScheduleService.ResumeSchedule + */ + resumeSchedule: { + methodKind: "unary"; + input: typeof ResumeScheduleRequestSchema; + output: typeof ResumeScheduleResponseSchema; + }; + /** + * Batch-updates schedule priorities based on ordered list of IDs + * + * @generated from rpc schedule.v1.ScheduleService.ReorderSchedules + */ + reorderSchedules: { + methodKind: "unary"; + input: typeof ReorderSchedulesRequestSchema; + output: typeof ReorderSchedulesResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_schedule_v1_schedule, 0); diff --git a/client/src/protoFleet/api/generated/telemetry/v1/telemetry_pb.ts b/client/src/protoFleet/api/generated/telemetry/v1/telemetry_pb.ts new file mode 100644 index 000000000..68df763b8 --- /dev/null +++ b/client/src/protoFleet/api/generated/telemetry/v1/telemetry_pb.ts @@ -0,0 +1,1066 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file telemetry/v1/telemetry.proto (package telemetry.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Duration, Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_duration, file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import { file_buf_validate_validate } from "../../buf/validate/validate_pb"; +import type { MeasurementUnit } from "../../common/v1/measurement_pb"; +import { file_common_v1_measurement } from "../../common/v1/measurement_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file telemetry/v1/telemetry.proto. + */ +export const file_telemetry_v1_telemetry: GenFile = + /*@__PURE__*/ + fileDesc( + "Chx0ZWxlbWV0cnkvdjEvdGVsZW1ldHJ5LnByb3RvEgx0ZWxlbWV0cnkudjEiagoORGV2aWNlU2VsZWN0b3ISFQoLYWxsX2RldmljZXMYASABKAhIABIvCgtkZXZpY2VfbGlzdBgCIAEoCzIYLnRlbGVtZXRyeS52MS5EZXZpY2VMaXN0SABCEAoOc2VsZWN0b3JfdmFsdWUiIAoKRGV2aWNlTGlzdBISCgpkZXZpY2VfaWRzGAEgAygJIpgBChZUZW1wZXJhdHVyZVN0YXR1c0NvdW50Ei0KCXRpbWVzdGFtcBgBIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASEgoKY29sZF9jb3VudBgCIAEoBRIQCghva19jb3VudBgDIAEoBRIRCglob3RfY291bnQYBCABKAUSFgoOY3JpdGljYWxfY291bnQYBSABKAUidAoRVXB0aW1lU3RhdHVzQ291bnQSLQoJdGltZXN0YW1wGAEgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIVCg1oYXNoaW5nX2NvdW50GAIgASgFEhkKEW5vdF9oYXNoaW5nX2NvdW50GAMgASgFIo8BCglUaW1lUmFuZ2USMwoKc3RhcnRfdGltZRgBIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBIAIgBARIxCghlbmRfdGltZRgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBIAYgBAUINCgtfc3RhcnRfdGltZUILCglfZW5kX3RpbWUipQIKDVRlbGVtZXRyeURhdGESEQoJZGV2aWNlX2lkGAEgASgJEjcKEG1lYXN1cmVtZW50X3R5cGUYAiABKA4yHS50ZWxlbWV0cnkudjEuTWVhc3VyZW1lbnRUeXBlEg0KBXZhbHVlGAMgASgBEigKBHVuaXQYBCABKA4yGi5jb21tb24udjEuTWVhc3VyZW1lbnRVbml0Ei0KCXRpbWVzdGFtcBgFIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASMwoEdGFncxgGIAMoCzIlLnRlbGVtZXRyeS52MS5UZWxlbWV0cnlEYXRhLlRhZ3NFbnRyeRorCglUYWdzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASLIAgoORGV2aWNlTWV0YWRhdGESEQoJZGV2aWNlX2lkGAEgASgJEhgKC2RldmljZV90eXBlGAIgASgJSACIAQESLQoJbGFzdF9zZWVuGAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBItCgZzdGF0dXMYBCABKA4yHS50ZWxlbWV0cnkudjEuQ29tcG9uZW50U3RhdHVzEhUKCGxvY2F0aW9uGAUgASgJSAGIAQESNAoEdGFncxgGIAMoCzImLnRlbGVtZXRyeS52MS5EZXZpY2VNZXRhZGF0YS5UYWdzRW50cnkSFAoMY2FwYWJpbGl0aWVzGAcgAygJGisKCVRhZ3NFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBQg4KDF9kZXZpY2VfdHlwZUILCglfbG9jYXRpb24i1AIKE0FnZ3JlZ2F0ZWRUZWxlbWV0cnkSEQoJZGV2aWNlX2lkGAEgASgJEjcKEG1lYXN1cmVtZW50X3R5cGUYAiABKA4yHS50ZWxlbWV0cnkudjEuTWVhc3VyZW1lbnRUeXBlEg0KBXZhbHVlGAMgASgBEjcKEGFnZ3JlZ2F0aW9uX3R5cGUYBCABKA4yHS50ZWxlbWV0cnkudjEuQWdncmVnYXRpb25UeXBlEhMKC2RhdGFfcG9pbnRzGAUgASgFEiwKC3RpbWVfd2luZG93GAYgASgLMhcudGVsZW1ldHJ5LnYxLlRpbWVSYW5nZRI5CgR0YWdzGAcgAygLMisudGVsZW1ldHJ5LnYxLkFnZ3JlZ2F0ZWRUZWxlbWV0cnkuVGFnc0VudHJ5GisKCVRhZ3NFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIm4KEE1pbmVyU3RhdGVDb3VudHMSFQoNaGFzaGluZ19jb3VudBgBIAEoBRIUCgxicm9rZW5fY291bnQYAiABKAUSFQoNb2ZmbGluZV9jb3VudBgDIAEoBRIWCg5zbGVlcGluZ19jb3VudBgEIAEoBSLWAwoPVGVsZW1ldHJ5VXBkYXRlEiYKBHR5cGUYASABKA4yGC50ZWxlbWV0cnkudjEuVXBkYXRlVHlwZRIWCglkZXZpY2VfaWQYAiABKAlIAIgBARItCgl0aW1lc3RhbXAYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KBGRhdGEYBCABKAsyGy50ZWxlbWV0cnkudjEuVGVsZW1ldHJ5RGF0YUgBiAEBEhoKDWVycm9yX21lc3NhZ2UYBSABKAlIAogBARIyCgZzdGF0dXMYBiABKA4yHS50ZWxlbWV0cnkudjEuQ29tcG9uZW50U3RhdHVzSAOIAQESNgoNZGV2aWNlX3N0YXR1cxgHIAEoDjIaLnRlbGVtZXRyeS52MS5EZXZpY2VTdGF0dXNIBIgBARI/ChJtaW5lcl9zdGF0ZV9jb3VudHMYCCABKAsyHi50ZWxlbWV0cnkudjEuTWluZXJTdGF0ZUNvdW50c0gFiAEBQgwKCl9kZXZpY2VfaWRCBwoFX2RhdGFCEAoOX2Vycm9yX21lc3NhZ2VCCQoHX3N0YXR1c0IQCg5fZGV2aWNlX3N0YXR1c0IVChNfbWluZXJfc3RhdGVfY291bnRzIuwDChlHZXRDb21iaW5lZE1ldHJpY3NSZXF1ZXN0Ej0KD2RldmljZV9zZWxlY3RvchgBIAEoCzIcLnRlbGVtZXRyeS52MS5EZXZpY2VTZWxlY3RvckIGukgDyAEBEjgKEW1lYXN1cmVtZW50X3R5cGVzGAMgAygOMh0udGVsZW1ldHJ5LnYxLk1lYXN1cmVtZW50VHlwZRIzCgxhZ2dyZWdhdGlvbnMYBCADKA4yHS50ZWxlbWV0cnkudjEuQWdncmVnYXRpb25UeXBlEi4KC2dyYW51bGFyaXR5GAUgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uEjYKCnN0YXJ0X3RpbWUYBiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQga6SAPIAQESLAoIZW5kX3RpbWUYByABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhIKCnBhZ2VfdG9rZW4YCCABKAkSEQoJcGFnZV9zaXplGAkgASgFOmS6SGEaXwoZZW5kX3RpbWVfYWZ0ZXJfc3RhcnRfdGltZRIhZW5kX3RpbWUgbXVzdCBiZSBhZnRlciBzdGFydF90aW1lGh90aGlzLmVuZF90aW1lID4gdGhpcy5zdGFydF90aW1lIlkKD0FnZ3JlZ2F0ZWRWYWx1ZRI3ChBhZ2dyZWdhdGlvbl90eXBlGAEgASgOMh0udGVsZW1ldHJ5LnYxLkFnZ3JlZ2F0aW9uVHlwZRINCgV2YWx1ZRgCIAEoASLAAQoGTWV0cmljEjcKEG1lYXN1cmVtZW50X3R5cGUYASABKA4yHS50ZWxlbWV0cnkudjEuTWVhc3VyZW1lbnRUeXBlEi0KCW9wZW5fdGltZRgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASOAoRYWdncmVnYXRlZF92YWx1ZXMYAyADKAsyHS50ZWxlbWV0cnkudjEuQWdncmVnYXRlZFZhbHVlEhQKDGRldmljZV9jb3VudBgEIAEoBSLkAQoaR2V0Q29tYmluZWRNZXRyaWNzUmVzcG9uc2USJQoHbWV0cmljcxgBIAMoCzIULnRlbGVtZXRyeS52MS5NZXRyaWMSFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJEkcKGXRlbXBlcmF0dXJlX3N0YXR1c19jb3VudHMYAyADKAsyJC50ZWxlbWV0cnkudjEuVGVtcGVyYXR1cmVTdGF0dXNDb3VudBI9ChR1cHRpbWVfc3RhdHVzX2NvdW50cxgEIAMoCzIfLnRlbGVtZXRyeS52MS5VcHRpbWVTdGF0dXNDb3VudCK+AgoiU3RyZWFtQ29tYmluZWRNZXRyaWNVcGRhdGVzUmVxdWVzdBI9Cg9kZXZpY2Vfc2VsZWN0b3IYASABKAsyHC50ZWxlbWV0cnkudjEuRGV2aWNlU2VsZWN0b3JCBrpIA8gBARIuCgdtZXRyaWNzGAIgAygOMh0udGVsZW1ldHJ5LnYxLk1lYXN1cmVtZW50VHlwZRIzCgxhZ2dyZWdhdGlvbnMYAyADKA4yHS50ZWxlbWV0cnkudjEuQWdncmVnYXRpb25UeXBlEkAKC2dyYW51bGFyaXR5GAQgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uQhC6SA2qAQoiBAiAowUyAggKEjIKD3VwZGF0ZV9pbnRlcnZhbBgFIAEoCzIZLmdvb2dsZS5wcm90b2J1Zi5EdXJhdGlvbiLGAgojU3RyZWFtQ29tYmluZWRNZXRyaWNVcGRhdGVzUmVzcG9uc2USJQoHbWV0cmljcxgBIAMoCzIULnRlbGVtZXRyeS52MS5NZXRyaWMSNAoQbmV4dF91cGRhdGVfdGltZRgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASRwoZdGVtcGVyYXR1cmVfc3RhdHVzX2NvdW50cxgDIAMoCzIkLnRlbGVtZXRyeS52MS5UZW1wZXJhdHVyZVN0YXR1c0NvdW50Ej0KFHVwdGltZV9zdGF0dXNfY291bnRzGAQgAygLMh8udGVsZW1ldHJ5LnYxLlVwdGltZVN0YXR1c0NvdW50EjoKEm1pbmVyX3N0YXRlX2NvdW50cxgFIAEoCzIeLnRlbGVtZXRyeS52MS5NaW5lclN0YXRlQ291bnRzKssCCg9NZWFzdXJlbWVudFR5cGUSIAocTUVBU1VSRU1FTlRfVFlQRV9VTlNQRUNJRklFRBAAEiAKHE1FQVNVUkVNRU5UX1RZUEVfVEVNUEVSQVRVUkUQARIdChlNRUFTVVJFTUVOVF9UWVBFX0hBU0hSQVRFEAISGgoWTUVBU1VSRU1FTlRfVFlQRV9QT1dFUhADEh8KG01FQVNVUkVNRU5UX1RZUEVfRUZGSUNJRU5DWRAEEh4KGk1FQVNVUkVNRU5UX1RZUEVfRkFOX1NQRUVEEAUSHAoYTUVBU1VSRU1FTlRfVFlQRV9WT0xUQUdFEAYSHAoYTUVBU1VSRU1FTlRfVFlQRV9DVVJSRU5UEAcSGwoXTUVBU1VSRU1FTlRfVFlQRV9VUFRJTUUQCBIfChtNRUFTVVJFTUVOVF9UWVBFX0VSUk9SX1JBVEUQCSq9AgoPQWdncmVnYXRpb25UeXBlEiAKHEFHR1JFR0FUSU9OX1RZUEVfVU5TUEVDSUZJRUQQABIcChhBR0dSRUdBVElPTl9UWVBFX0FWRVJBR0UQARIYChRBR0dSRUdBVElPTl9UWVBFX01JThACEhgKFEFHR1JFR0FUSU9OX1RZUEVfTUFYEAMSGAoUQUdHUkVHQVRJT05fVFlQRV9TVU0QBBIjCh9BR0dSRUdBVElPTl9UWVBFX0ZJUlNUX1FVQVJUSUxFEAUSGwoXQUdHUkVHQVRJT05fVFlQRV9NRURJQU4QBhIjCh9BR0dSRUdBVElPTl9UWVBFX1RISVJEX1FVQVJUSUxFEAcSGgoWQUdHUkVHQVRJT05fVFlQRV9GSVJTVBAIEhkKFUFHR1JFR0FUSU9OX1RZUEVfTEFTVBAJKqwBCg9Db21wb25lbnRTdGF0dXMSIAocQ09NUE9ORU5UX1NUQVRVU19VTlNQRUNJRklFRBAAEhwKGENPTVBPTkVOVF9TVEFUVVNfSEVBTFRIWRABEhwKGENPTVBPTkVOVF9TVEFUVVNfV0FSTklORxACEh0KGUNPTVBPTkVOVF9TVEFUVVNfQ1JJVElDQUwQAxIcChhDT01QT05FTlRfU1RBVFVTX09GRkxJTkUQBCqsAQoRVGVtcGVyYXR1cmVTdGF0dXMSIgoeVEVNUEVSQVRVUkVfU1RBVFVTX1VOU1BFQ0lGSUVEEAASGwoXVEVNUEVSQVRVUkVfU1RBVFVTX0NPTEQQARIZChVURU1QRVJBVFVSRV9TVEFUVVNfT0sQAhIaChZURU1QRVJBVFVSRV9TVEFUVVNfSE9UEAMSHwobVEVNUEVSQVRVUkVfU1RBVFVTX0NSSVRJQ0FMEAQqmgIKDERldmljZVN0YXR1cxIdChlERVZJQ0VfU1RBVFVTX1VOU1BFQ0lGSUVEEAASGAoUREVWSUNFX1NUQVRVU19PTkxJTkUQARIZChVERVZJQ0VfU1RBVFVTX09GRkxJTkUQAhIdChlERVZJQ0VfU1RBVFVTX01BSU5URU5BTkNFEAMSFwoTREVWSUNFX1NUQVRVU19FUlJPUhAEEhoKFkRFVklDRV9TVEFUVVNfSU5BQ1RJVkUQBRIjCh9ERVZJQ0VfU1RBVFVTX05FRURTX01JTklOR19QT09MEAYSGgoWREVWSUNFX1NUQVRVU19VUERBVElORxAHEiEKHURFVklDRV9TVEFUVVNfUkVCT09UX1JFUVVJUkVEEAgquQEKClVwZGF0ZVR5cGUSGwoXVVBEQVRFX1RZUEVfVU5TUEVDSUZJRUQQABIZChVVUERBVEVfVFlQRV9URUxFTUVUUlkQARIZChVVUERBVEVfVFlQRV9IRUFSVEJFQVQQAhIVChFVUERBVEVfVFlQRV9FUlJPUhADEh0KGVVQREFURV9UWVBFX0RFVklDRV9TVEFUVVMQBBIiCh5VUERBVEVfVFlQRV9NSU5FUl9TVEFURV9DT1VOVFMQBTKGAgoQVGVsZW1ldHJ5U2VydmljZRJpChJHZXRDb21iaW5lZE1ldHJpY3MSJy50ZWxlbWV0cnkudjEuR2V0Q29tYmluZWRNZXRyaWNzUmVxdWVzdBooLnRlbGVtZXRyeS52MS5HZXRDb21iaW5lZE1ldHJpY3NSZXNwb25zZSIAEoYBChtTdHJlYW1Db21iaW5lZE1ldHJpY1VwZGF0ZXMSMC50ZWxlbWV0cnkudjEuU3RyZWFtQ29tYmluZWRNZXRyaWNVcGRhdGVzUmVxdWVzdBoxLnRlbGVtZXRyeS52MS5TdHJlYW1Db21iaW5lZE1ldHJpY1VwZGF0ZXNSZXNwb25zZSIAMAFCwAEKEGNvbS50ZWxlbWV0cnkudjFCDlRlbGVtZXRyeVByb3RvUAFaS2dpdGh1Yi5jb20vYmxvY2svcHJvdG8tZmxlZXQvc2VydmVyL2dlbmVyYXRlZC9ncnBjL3RlbGVtZXRyeS92MTt0ZWxlbWV0cnl2MaICA1RYWKoCDFRlbGVtZXRyeS5WMcoCDFRlbGVtZXRyeVxWMeICGFRlbGVtZXRyeVxWMVxHUEJNZXRhZGF0YeoCDVRlbGVtZXRyeTo6VjFiBnByb3RvMw", + [ + file_google_protobuf_timestamp, + file_google_protobuf_duration, + file_buf_validate_validate, + file_common_v1_measurement, + ], + ); + +/** + * @generated from message telemetry.v1.DeviceSelector + */ +export type DeviceSelector = Message<"telemetry.v1.DeviceSelector"> & { + /** + * @generated from oneof telemetry.v1.DeviceSelector.selector_value + */ + selectorValue: + | { + /** + * Select all devices in the org + * + * @generated from field: bool all_devices = 1; + */ + value: boolean; + case: "allDevices"; + } + | { + /** + * Select specific devices by ID + * + * @generated from field: telemetry.v1.DeviceList device_list = 2; + */ + value: DeviceList; + case: "deviceList"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message telemetry.v1.DeviceSelector. + * Use `create(DeviceSelectorSchema)` to create a new message. + */ +export const DeviceSelectorSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_telemetry_v1_telemetry, 0); + +/** + * @generated from message telemetry.v1.DeviceList + */ +export type DeviceList = Message<"telemetry.v1.DeviceList"> & { + /** + * List of device identifiers (e.g., "proto-miner-001"). + * Note: Despite the field name, these are unique device identifier strings, + * not database primary key IDs. + * + * @generated from field: repeated string device_ids = 1; + */ + deviceIds: string[]; +}; + +/** + * Describes the message telemetry.v1.DeviceList. + * Use `create(DeviceListSchema)` to create a new message. + */ +export const DeviceListSchema: GenMessage = /*@__PURE__*/ messageDesc(file_telemetry_v1_telemetry, 1); + +/** + * Temperature status distribution at a point in time + * + * @generated from message telemetry.v1.TemperatureStatusCount + */ +export type TemperatureStatusCount = Message<"telemetry.v1.TemperatureStatusCount"> & { + /** + * @generated from field: google.protobuf.Timestamp timestamp = 1; + */ + timestamp?: Timestamp; + + /** + * Count of miners < 0°C + * + * @generated from field: int32 cold_count = 2; + */ + coldCount: number; + + /** + * Count of miners 0-70°C + * + * @generated from field: int32 ok_count = 3; + */ + okCount: number; + + /** + * Count of miners 70-90°C + * + * @generated from field: int32 hot_count = 4; + */ + hotCount: number; + + /** + * Count of miners > 90°C + * + * @generated from field: int32 critical_count = 5; + */ + criticalCount: number; +}; + +/** + * Describes the message telemetry.v1.TemperatureStatusCount. + * Use `create(TemperatureStatusCountSchema)` to create a new message. + */ +export const TemperatureStatusCountSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_telemetry_v1_telemetry, 2); + +/** + * Uptime status distribution at a point in time + * + * @generated from message telemetry.v1.UptimeStatusCount + */ +export type UptimeStatusCount = Message<"telemetry.v1.UptimeStatusCount"> & { + /** + * @generated from field: google.protobuf.Timestamp timestamp = 1; + */ + timestamp?: Timestamp; + + /** + * Count of miners actively hashing + * + * @generated from field: int32 hashing_count = 2; + */ + hashingCount: number; + + /** + * Count of miners not hashing + * + * @generated from field: int32 not_hashing_count = 3; + */ + notHashingCount: number; +}; + +/** + * Describes the message telemetry.v1.UptimeStatusCount. + * Use `create(UptimeStatusCountSchema)` to create a new message. + */ +export const UptimeStatusCountSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_telemetry_v1_telemetry, 3); + +/** + * Time range using optional fields (best practice) + * + * @generated from message telemetry.v1.TimeRange + */ +export type TimeRange = Message<"telemetry.v1.TimeRange"> & { + /** + * @generated from field: optional google.protobuf.Timestamp start_time = 1; + */ + startTime?: Timestamp; + + /** + * @generated from field: optional google.protobuf.Timestamp end_time = 2; + */ + endTime?: Timestamp; +}; + +/** + * Describes the message telemetry.v1.TimeRange. + * Use `create(TimeRangeSchema)` to create a new message. + */ +export const TimeRangeSchema: GenMessage = /*@__PURE__*/ messageDesc(file_telemetry_v1_telemetry, 4); + +/** + * Telemetry data structure + * + * @generated from message telemetry.v1.TelemetryData + */ +export type TelemetryData = Message<"telemetry.v1.TelemetryData"> & { + /** + * The device identifier string (e.g., "proto-miner-001"). + * Note: Despite the field name, this is the unique device identifier, + * not the database primary key. + * + * @generated from field: string device_id = 1; + */ + deviceId: string; + + /** + * @generated from field: telemetry.v1.MeasurementType measurement_type = 2; + */ + measurementType: MeasurementType; + + /** + * @generated from field: double value = 3; + */ + value: number; + + /** + * @generated from field: common.v1.MeasurementUnit unit = 4; + */ + unit: MeasurementUnit; + + /** + * @generated from field: google.protobuf.Timestamp timestamp = 5; + */ + timestamp?: Timestamp; + + /** + * @generated from field: map tags = 6; + */ + tags: { [key: string]: string }; +}; + +/** + * Describes the message telemetry.v1.TelemetryData. + * Use `create(TelemetryDataSchema)` to create a new message. + */ +export const TelemetryDataSchema: GenMessage = /*@__PURE__*/ messageDesc(file_telemetry_v1_telemetry, 5); + +/** + * Device metadata + * + * @generated from message telemetry.v1.DeviceMetadata + */ +export type DeviceMetadata = Message<"telemetry.v1.DeviceMetadata"> & { + /** + * The device identifier string (e.g., "proto-miner-001"). + * Note: Despite the field name, this is the unique device identifier, + * not the database primary key. + * + * @generated from field: string device_id = 1; + */ + deviceId: string; + + /** + * @generated from field: optional string device_type = 2; + */ + deviceType?: string; + + /** + * @generated from field: google.protobuf.Timestamp last_seen = 3; + */ + lastSeen?: Timestamp; + + /** + * @generated from field: telemetry.v1.ComponentStatus status = 4; + */ + status: ComponentStatus; + + /** + * @generated from field: optional string location = 5; + */ + location?: string; + + /** + * @generated from field: map tags = 6; + */ + tags: { [key: string]: string }; + + /** + * @generated from field: repeated string capabilities = 7; + */ + capabilities: string[]; +}; + +/** + * Describes the message telemetry.v1.DeviceMetadata. + * Use `create(DeviceMetadataSchema)` to create a new message. + */ +export const DeviceMetadataSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_telemetry_v1_telemetry, 6); + +/** + * Aggregated telemetry result + * + * @generated from message telemetry.v1.AggregatedTelemetry + */ +export type AggregatedTelemetry = Message<"telemetry.v1.AggregatedTelemetry"> & { + /** + * The device identifier string (e.g., "proto-miner-001"). + * Note: Despite the field name, this is the unique device identifier, + * not the database primary key. + * + * @generated from field: string device_id = 1; + */ + deviceId: string; + + /** + * @generated from field: telemetry.v1.MeasurementType measurement_type = 2; + */ + measurementType: MeasurementType; + + /** + * @generated from field: double value = 3; + */ + value: number; + + /** + * @generated from field: telemetry.v1.AggregationType aggregation_type = 4; + */ + aggregationType: AggregationType; + + /** + * @generated from field: int32 data_points = 5; + */ + dataPoints: number; + + /** + * @generated from field: telemetry.v1.TimeRange time_window = 6; + */ + timeWindow?: TimeRange; + + /** + * @generated from field: map tags = 7; + */ + tags: { [key: string]: string }; +}; + +/** + * Describes the message telemetry.v1.AggregatedTelemetry. + * Use `create(AggregatedTelemetrySchema)` to create a new message. + */ +export const AggregatedTelemetrySchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_telemetry_v1_telemetry, 7); + +/** + * Represents counts of miners in different states with status-first priority: + * 1. Offline (OFFLINE/NULL status) - highest priority + * 2. Sleeping (MAINTENANCE/INACTIVE status) - second priority + * 3. Needs Attention (NEEDS_MINING_POOL/ERROR/AUTHENTICATION_NEEDED/errors) - only if not offline or sleeping + * 4. Hashing (ACTIVE, no auth needed, no errors) - only if none of the above + * TODO: align the name of the messages, to match the filters MinerStatus -> DeviceStatus + * + * @generated from message telemetry.v1.MinerStateCounts + */ +export type MinerStateCounts = Message<"telemetry.v1.MinerStateCounts"> & { + /** + * Number of miners that are hashing (ACTIVE status, no AUTHENTICATION_NEEDED, no open errors) + * Only counted if not offline, sleeping, or needs attention + * + * @generated from field: int32 hashing_count = 1; + */ + hashingCount: number; + + /** + * Number of miners that need attention (NEEDS_MINING_POOL OR ERROR status OR AUTHENTICATION_NEEDED OR open CRITICAL/MAJOR/MINOR errors) + * Only counted if not offline or sleeping - status takes priority over errors + * + * @generated from field: int32 broken_count = 2; + */ + brokenCount: number; + + /** + * Number of miners that are offline (OFFLINE or NULL status) + * Highest priority - includes devices with AUTHENTICATION_NEEDED and/or open errors + * + * @generated from field: int32 offline_count = 3; + */ + offlineCount: number; + + /** + * Number of miners that are sleeping (MAINTENANCE or INACTIVE status) + * Second priority - includes devices with AUTHENTICATION_NEEDED and/or open errors + * + * @generated from field: int32 sleeping_count = 4; + */ + sleepingCount: number; +}; + +/** + * Describes the message telemetry.v1.MinerStateCounts. + * Use `create(MinerStateCountsSchema)` to create a new message. + */ +export const MinerStateCountsSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_telemetry_v1_telemetry, 8); + +/** + * Streaming update + * + * @generated from message telemetry.v1.TelemetryUpdate + */ +export type TelemetryUpdate = Message<"telemetry.v1.TelemetryUpdate"> & { + /** + * @generated from field: telemetry.v1.UpdateType type = 1; + */ + type: UpdateType; + + /** + * The device identifier string (e.g., "proto-miner-001"). + * Note: Despite the field name, this is the unique device identifier, + * not the database primary key. + * + * @generated from field: optional string device_id = 2; + */ + deviceId?: string; + + /** + * @generated from field: google.protobuf.Timestamp timestamp = 3; + */ + timestamp?: Timestamp; + + /** + * @generated from field: optional telemetry.v1.TelemetryData data = 4; + */ + data?: TelemetryData; + + /** + * @generated from field: optional string error_message = 5; + */ + errorMessage?: string; + + /** + * @generated from field: optional telemetry.v1.ComponentStatus status = 6; + */ + status?: ComponentStatus; + + /** + * e.g., ACTIVE, INACTIVE, ERROR + * + * @generated from field: optional telemetry.v1.DeviceStatus device_status = 7; + */ + deviceStatus?: DeviceStatus; + + /** + * @generated from field: optional telemetry.v1.MinerStateCounts miner_state_counts = 8; + */ + minerStateCounts?: MinerStateCounts; +}; + +/** + * Describes the message telemetry.v1.TelemetryUpdate. + * Use `create(TelemetryUpdateSchema)` to create a new message. + */ +export const TelemetryUpdateSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_telemetry_v1_telemetry, 9); + +/** + * @generated from message telemetry.v1.GetCombinedMetricsRequest + */ +export type GetCombinedMetricsRequest = Message<"telemetry.v1.GetCombinedMetricsRequest"> & { + /** + * Select devices by ID or all devices in org + * + * @generated from field: telemetry.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; + + /** + * e.g., TEMPERATURE, HASHRATE Defaults to all types + * + * @generated from field: repeated telemetry.v1.MeasurementType measurement_types = 3; + */ + measurementTypes: MeasurementType[]; + + /** + * e.g., AVERAGE, MIN, MAX Defaults to all aggregations + * + * @generated from field: repeated telemetry.v1.AggregationType aggregations = 4; + */ + aggregations: AggregationType[]; + + /** + * e.g., 1m, 5m, 1h defaults to 10s + * + * @generated from field: google.protobuf.Duration granularity = 5; + */ + granularity?: Duration; + + /** + * inclusive + * + * @generated from field: google.protobuf.Timestamp start_time = 6; + */ + startTime?: Timestamp; + + /** + * @generated from field: google.protobuf.Timestamp end_time = 7; + */ + endTime?: Timestamp; + + /** + * for pagination + * + * @generated from field: string page_token = 8; + */ + pageToken: string; + + /** + * max 1000, default 100 + * + * @generated from field: int32 page_size = 9; + */ + pageSize: number; +}; + +/** + * Describes the message telemetry.v1.GetCombinedMetricsRequest. + * Use `create(GetCombinedMetricsRequestSchema)` to create a new message. + */ +export const GetCombinedMetricsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_telemetry_v1_telemetry, 10); + +/** + * @generated from message telemetry.v1.AggregatedValue + */ +export type AggregatedValue = Message<"telemetry.v1.AggregatedValue"> & { + /** + * @generated from field: telemetry.v1.AggregationType aggregation_type = 1; + */ + aggregationType: AggregationType; + + /** + * e.g., 75.5 for AVERAGE, 1000 for MAX + * + * @generated from field: double value = 2; + */ + value: number; +}; + +/** + * Describes the message telemetry.v1.AggregatedValue. + * Use `create(AggregatedValueSchema)` to create a new message. + */ +export const AggregatedValueSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_telemetry_v1_telemetry, 11); + +/** + * @generated from message telemetry.v1.Metric + */ +export type Metric = Message<"telemetry.v1.Metric"> & { + /** + * @generated from field: telemetry.v1.MeasurementType measurement_type = 1; + */ + measurementType: MeasurementType; + + /** + * @generated from field: google.protobuf.Timestamp open_time = 2; + */ + openTime?: Timestamp; + + /** + * e.g., AVERAGE, MIN, MAX + * + * @generated from field: repeated telemetry.v1.AggregatedValue aggregated_values = 3; + */ + aggregatedValues: AggregatedValue[]; + + /** + * Number of devices reporting this metric + * + * @generated from field: int32 device_count = 4; + */ + deviceCount: number; +}; + +/** + * Describes the message telemetry.v1.Metric. + * Use `create(MetricSchema)` to create a new message. + */ +export const MetricSchema: GenMessage = /*@__PURE__*/ messageDesc(file_telemetry_v1_telemetry, 12); + +/** + * @generated from message telemetry.v1.GetCombinedMetricsResponse + */ +export type GetCombinedMetricsResponse = Message<"telemetry.v1.GetCombinedMetricsResponse"> & { + /** + * @generated from field: repeated telemetry.v1.Metric metrics = 1; + */ + metrics: Metric[]; + + /** + * for pagination + * + * @generated from field: string next_page_token = 2; + */ + nextPageToken: string; + + /** + * Temperature status distribution across the fleet + * + * @generated from field: repeated telemetry.v1.TemperatureStatusCount temperature_status_counts = 3; + */ + temperatureStatusCounts: TemperatureStatusCount[]; + + /** + * Uptime status distribution across the fleet + * + * @generated from field: repeated telemetry.v1.UptimeStatusCount uptime_status_counts = 4; + */ + uptimeStatusCounts: UptimeStatusCount[]; +}; + +/** + * Describes the message telemetry.v1.GetCombinedMetricsResponse. + * Use `create(GetCombinedMetricsResponseSchema)` to create a new message. + */ +export const GetCombinedMetricsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_telemetry_v1_telemetry, 13); + +/** + * @generated from message telemetry.v1.StreamCombinedMetricUpdatesRequest + */ +export type StreamCombinedMetricUpdatesRequest = Message<"telemetry.v1.StreamCombinedMetricUpdatesRequest"> & { + /** + * Select devices by ID or all devices in org + * + * @generated from field: telemetry.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; + + /** + * @generated from field: repeated telemetry.v1.MeasurementType metrics = 2; + */ + metrics: MeasurementType[]; + + /** + * e.g., AVERAGE, MIN, MAX + * + * @generated from field: repeated telemetry.v1.AggregationType aggregations = 3; + */ + aggregations: AggregationType[]; + + /** + * default granularity is 1 minute + * + * @generated from field: google.protobuf.Duration granularity = 4; + */ + granularity?: Duration; + + /** + * default update interval is granularity + * + * @generated from field: google.protobuf.Duration update_interval = 5; + */ + updateInterval?: Duration; +}; + +/** + * Describes the message telemetry.v1.StreamCombinedMetricUpdatesRequest. + * Use `create(StreamCombinedMetricUpdatesRequestSchema)` to create a new message. + */ +export const StreamCombinedMetricUpdatesRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_telemetry_v1_telemetry, 14); + +/** + * @generated from message telemetry.v1.StreamCombinedMetricUpdatesResponse + */ +export type StreamCombinedMetricUpdatesResponse = Message<"telemetry.v1.StreamCombinedMetricUpdatesResponse"> & { + /** + * e.g., TEMPERATURE, HASHRATE + * + * @generated from field: repeated telemetry.v1.Metric metrics = 1; + */ + metrics: Metric[]; + + /** + * when the next update will be sent + * + * @generated from field: google.protobuf.Timestamp next_update_time = 2; + */ + nextUpdateTime?: Timestamp; + + /** + * Real-time temperature status distribution + * + * @generated from field: repeated telemetry.v1.TemperatureStatusCount temperature_status_counts = 3; + */ + temperatureStatusCounts: TemperatureStatusCount[]; + + /** + * Real-time uptime status distribution + * + * @generated from field: repeated telemetry.v1.UptimeStatusCount uptime_status_counts = 4; + */ + uptimeStatusCounts: UptimeStatusCount[]; + + /** + * Real-time miner state counts (hashing, broken, offline, sleeping) + * + * @generated from field: telemetry.v1.MinerStateCounts miner_state_counts = 5; + */ + minerStateCounts?: MinerStateCounts; +}; + +/** + * Describes the message telemetry.v1.StreamCombinedMetricUpdatesResponse. + * Use `create(StreamCombinedMetricUpdatesResponseSchema)` to create a new message. + */ +export const StreamCombinedMetricUpdatesResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_telemetry_v1_telemetry, 15); + +/** + * Enums matching domain models + * + * @generated from enum telemetry.v1.MeasurementType + */ +export enum MeasurementType { + /** + * @generated from enum value: MEASUREMENT_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: MEASUREMENT_TYPE_TEMPERATURE = 1; + */ + TEMPERATURE = 1, + + /** + * @generated from enum value: MEASUREMENT_TYPE_HASHRATE = 2; + */ + HASHRATE = 2, + + /** + * @generated from enum value: MEASUREMENT_TYPE_POWER = 3; + */ + POWER = 3, + + /** + * @generated from enum value: MEASUREMENT_TYPE_EFFICIENCY = 4; + */ + EFFICIENCY = 4, + + /** + * @generated from enum value: MEASUREMENT_TYPE_FAN_SPEED = 5; + */ + FAN_SPEED = 5, + + /** + * @generated from enum value: MEASUREMENT_TYPE_VOLTAGE = 6; + */ + VOLTAGE = 6, + + /** + * @generated from enum value: MEASUREMENT_TYPE_CURRENT = 7; + */ + CURRENT = 7, + + /** + * @generated from enum value: MEASUREMENT_TYPE_UPTIME = 8; + */ + UPTIME = 8, + + /** + * @generated from enum value: MEASUREMENT_TYPE_ERROR_RATE = 9; + */ + ERROR_RATE = 9, +} + +/** + * Describes the enum telemetry.v1.MeasurementType. + */ +export const MeasurementTypeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_telemetry_v1_telemetry, 0); + +/** + * @generated from enum telemetry.v1.AggregationType + */ +export enum AggregationType { + /** + * @generated from enum value: AGGREGATION_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: AGGREGATION_TYPE_AVERAGE = 1; + */ + AVERAGE = 1, + + /** + * @generated from enum value: AGGREGATION_TYPE_MIN = 2; + */ + MIN = 2, + + /** + * @generated from enum value: AGGREGATION_TYPE_MAX = 3; + */ + MAX = 3, + + /** + * @generated from enum value: AGGREGATION_TYPE_SUM = 4; + */ + SUM = 4, + + /** + * @generated from enum value: AGGREGATION_TYPE_FIRST_QUARTILE = 5; + */ + FIRST_QUARTILE = 5, + + /** + * @generated from enum value: AGGREGATION_TYPE_MEDIAN = 6; + */ + MEDIAN = 6, + + /** + * @generated from enum value: AGGREGATION_TYPE_THIRD_QUARTILE = 7; + */ + THIRD_QUARTILE = 7, + + /** + * @generated from enum value: AGGREGATION_TYPE_FIRST = 8; + */ + FIRST = 8, + + /** + * @generated from enum value: AGGREGATION_TYPE_LAST = 9; + */ + LAST = 9, +} + +/** + * Describes the enum telemetry.v1.AggregationType. + */ +export const AggregationTypeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_telemetry_v1_telemetry, 1); + +/** + * @generated from enum telemetry.v1.ComponentStatus + */ +export enum ComponentStatus { + /** + * @generated from enum value: COMPONENT_STATUS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: COMPONENT_STATUS_HEALTHY = 1; + */ + HEALTHY = 1, + + /** + * @generated from enum value: COMPONENT_STATUS_WARNING = 2; + */ + WARNING = 2, + + /** + * @generated from enum value: COMPONENT_STATUS_CRITICAL = 3; + */ + CRITICAL = 3, + + /** + * @generated from enum value: COMPONENT_STATUS_OFFLINE = 4; + */ + OFFLINE = 4, +} + +/** + * Describes the enum telemetry.v1.ComponentStatus. + */ +export const ComponentStatusSchema: GenEnum = /*@__PURE__*/ enumDesc(file_telemetry_v1_telemetry, 2); + +/** + * Temperature status based on threshold ranges + * + * @generated from enum telemetry.v1.TemperatureStatus + */ +export enum TemperatureStatus { + /** + * @generated from enum value: TEMPERATURE_STATUS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Below 0°C + * + * @generated from enum value: TEMPERATURE_STATUS_COLD = 1; + */ + COLD = 1, + + /** + * 0°C to 70°C + * + * @generated from enum value: TEMPERATURE_STATUS_OK = 2; + */ + OK = 2, + + /** + * 70°C to 90°C + * + * @generated from enum value: TEMPERATURE_STATUS_HOT = 3; + */ + HOT = 3, + + /** + * Above 90°C + * + * @generated from enum value: TEMPERATURE_STATUS_CRITICAL = 4; + */ + CRITICAL = 4, +} + +/** + * Describes the enum telemetry.v1.TemperatureStatus. + */ +export const TemperatureStatusSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_telemetry_v1_telemetry, 3); + +/** + * Status of a miner + * + * @generated from enum telemetry.v1.DeviceStatus + */ +export enum DeviceStatus { + /** + * Status is unknown or not specified + * + * @generated from enum value: DEVICE_STATUS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Miner is online and functioning normally + * + * @generated from enum value: DEVICE_STATUS_ONLINE = 1; + */ + ONLINE = 1, + + /** + * Miner is offline and not responding + * + * @generated from enum value: DEVICE_STATUS_OFFLINE = 2; + */ + OFFLINE = 2, + + /** + * Miner is in maintenance mode + * + * @generated from enum value: DEVICE_STATUS_MAINTENANCE = 3; + */ + MAINTENANCE = 3, + + /** + * Miner is in error state + * + * @generated from enum value: DEVICE_STATUS_ERROR = 4; + */ + ERROR = 4, + + /** + * Miner is inactive, not mining but still connected + * + * @generated from enum value: DEVICE_STATUS_INACTIVE = 5; + */ + INACTIVE = 5, + + /** + * Miner is online but needs a mining pool configured to start mining + * + * @generated from enum value: DEVICE_STATUS_NEEDS_MINING_POOL = 6; + */ + NEEDS_MINING_POOL = 6, + + /** + * Miner is receiving a firmware update (install in progress on device) + * + * @generated from enum value: DEVICE_STATUS_UPDATING = 7; + */ + UPDATING = 7, + + /** + * Miner firmware has been installed but requires a reboot to activate + * + * @generated from enum value: DEVICE_STATUS_REBOOT_REQUIRED = 8; + */ + REBOOT_REQUIRED = 8, +} + +/** + * Describes the enum telemetry.v1.DeviceStatus. + */ +export const DeviceStatusSchema: GenEnum = /*@__PURE__*/ enumDesc(file_telemetry_v1_telemetry, 4); + +/** + * @generated from enum telemetry.v1.UpdateType + */ +export enum UpdateType { + /** + * @generated from enum value: UPDATE_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: UPDATE_TYPE_TELEMETRY = 1; + */ + TELEMETRY = 1, + + /** + * @generated from enum value: UPDATE_TYPE_HEARTBEAT = 2; + */ + HEARTBEAT = 2, + + /** + * @generated from enum value: UPDATE_TYPE_ERROR = 3; + */ + ERROR = 3, + + /** + * @generated from enum value: UPDATE_TYPE_DEVICE_STATUS = 4; + */ + DEVICE_STATUS = 4, + + /** + * Represents counts of miners in different states + * + * @generated from enum value: UPDATE_TYPE_MINER_STATE_COUNTS = 5; + */ + MINER_STATE_COUNTS = 5, +} + +/** + * Describes the enum telemetry.v1.UpdateType. + */ +export const UpdateTypeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_telemetry_v1_telemetry, 5); + +/** + * Service for retrieving telemetry data from mining devices + * + * @generated from service telemetry.v1.TelemetryService + */ +export const TelemetryService: GenService<{ + /** + * Historical, aggregated candles (pull). + * + * @generated from rpc telemetry.v1.TelemetryService.GetCombinedMetrics + */ + getCombinedMetrics: { + methodKind: "unary"; + input: typeof GetCombinedMetricsRequestSchema; + output: typeof GetCombinedMetricsResponseSchema; + }; + /** + * Live updates pushed by the server (used by dashboard). + * + * @generated from rpc telemetry.v1.TelemetryService.StreamCombinedMetricUpdates + */ + streamCombinedMetricUpdates: { + methodKind: "server_streaming"; + input: typeof StreamCombinedMetricUpdatesRequestSchema; + output: typeof StreamCombinedMetricUpdatesResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_telemetry_v1_telemetry, 0); diff --git a/client/src/protoFleet/api/getErrorMessage.test.ts b/client/src/protoFleet/api/getErrorMessage.test.ts new file mode 100644 index 000000000..d7a9fe014 --- /dev/null +++ b/client/src/protoFleet/api/getErrorMessage.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { Code, ConnectError } from "@connectrpc/connect"; +import { getErrorMessage } from "./getErrorMessage"; + +describe("getErrorMessage", () => { + describe("ConnectError prefix stripping", () => { + it("strips [internal] prefix", () => { + const err = new ConnectError("Log bundle too large to download!", Code.Internal); + expect(err.message).toContain("[internal]"); + expect(getErrorMessage(err)).toBe("Log bundle too large to download!"); + }); + + it("strips [not_found] prefix", () => { + const err = new ConnectError("device not found", Code.NotFound); + expect(err.message).toContain("[not_found]"); + expect(getErrorMessage(err)).toBe("device not found"); + }); + + it("strips [already_exists] prefix", () => { + const err = new ConnectError("a collection with this name already exists", Code.AlreadyExists); + expect(err.message).toContain("[already_exists]"); + expect(getErrorMessage(err)).toBe("a collection with this name already exists"); + }); + + it("strips [invalid_argument] prefix", () => { + const err = new ConnectError("username is required", Code.InvalidArgument); + expect(err.message).toContain("[invalid_argument]"); + expect(getErrorMessage(err)).toBe("username is required"); + }); + + it("strips [permission_denied] prefix", () => { + const err = new ConnectError("access denied", Code.PermissionDenied); + expect(err.message).toContain("[permission_denied]"); + expect(getErrorMessage(err)).toBe("access denied"); + }); + }); + + describe("fallback behavior", () => { + it("returns fallback when ConnectError has an empty message", () => { + const err = new ConnectError("", Code.Internal); + expect(getErrorMessage(err, "Something went wrong")).toBe("Something went wrong"); + }); + + it("returns empty string when ConnectError has an empty message and no fallback", () => { + const err = new ConnectError("", Code.Internal); + expect(getErrorMessage(err)).toBe(""); + }); + + it("prefers rawMessage over fallback when both are present", () => { + const err = new ConnectError("specific error", Code.Internal); + expect(getErrorMessage(err, "generic fallback")).toBe("specific error"); + }); + }); + + describe("non-ConnectError inputs", () => { + it("extracts message from a plain Error", () => { + const err = new Error("something broke"); + expect(getErrorMessage(err)).toBe("something broke"); + }); + + it("extracts message from a TypeError", () => { + const err = new TypeError("cannot read property of null"); + expect(getErrorMessage(err)).toBe("cannot read property of null"); + }); + + it("converts a string input to message", () => { + expect(getErrorMessage("raw string error")).toBe("raw string error"); + }); + + it("handles null without crashing", () => { + expect(getErrorMessage(null)).toBe("null"); + }); + + it("handles undefined without crashing", () => { + expect(getErrorMessage(undefined)).toBe("undefined"); + }); + + it("handles a number without crashing", () => { + expect(getErrorMessage(42)).toBe("42"); + }); + + it("uses fallback for non-Error inputs with empty string conversion", () => { + expect(getErrorMessage("", "default message")).toBe("default message"); + }); + }); +}); diff --git a/client/src/protoFleet/api/getErrorMessage.ts b/client/src/protoFleet/api/getErrorMessage.ts new file mode 100644 index 000000000..91ebed478 --- /dev/null +++ b/client/src/protoFleet/api/getErrorMessage.ts @@ -0,0 +1,10 @@ +import { ConnectError } from "@connectrpc/connect"; + +/** + * Extracts user-facing error message from a Connect RPC error. + * Strips protocol-level prefixes like "[internal]" that ConnectError.message includes. + * If a fallback is provided, it is returned when the raw message is empty. + */ +export function getErrorMessage(err: unknown, fallback?: string): string { + return ConnectError.from(err).rawMessage || fallback || ""; +} diff --git a/client/src/protoFleet/api/scheduleEvents.ts b/client/src/protoFleet/api/scheduleEvents.ts new file mode 100644 index 000000000..a6ef55c32 --- /dev/null +++ b/client/src/protoFleet/api/scheduleEvents.ts @@ -0,0 +1,9 @@ +export const SCHEDULES_CHANGED_EVENT = "protoFleet:schedules-changed"; + +export const emitSchedulesChanged = () => { + if (typeof window === "undefined") { + return; + } + + window.dispatchEvent(new CustomEvent(SCHEDULES_CHANGED_EVENT)); +}; diff --git a/client/src/protoFleet/api/transport.ts b/client/src/protoFleet/api/transport.ts new file mode 100644 index 000000000..c23c7c82f --- /dev/null +++ b/client/src/protoFleet/api/transport.ts @@ -0,0 +1,10 @@ +import { createConnectTransport } from "@connectrpc/connect-web"; +import { API_PROXY_BASE } from "@/protoFleet/api/constants"; + +const transport = createConnectTransport({ + baseUrl: `${API_PROXY_BASE}/`, + // Include cookies with all requests for session-based authentication + fetch: (input, init) => fetch(input, { ...init, credentials: "include" }), +}); + +export { transport }; diff --git a/client/src/protoFleet/api/useActivity.test.ts b/client/src/protoFleet/api/useActivity.test.ts new file mode 100644 index 000000000..be64ac42c --- /dev/null +++ b/client/src/protoFleet/api/useActivity.test.ts @@ -0,0 +1,223 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import { activityClient } from "./clients"; +import { useActivity } from "./useActivity"; +import { + ActivityEntrySchema, + type ActivityFilter, + ActivityFilterSchema, + ListActivitiesResponseSchema, +} from "@/protoFleet/api/generated/activity/v1/activity_pb"; + +vi.mock("./clients", () => ({ + activityClient: { + listActivities: vi.fn(), + }, +})); + +const mockHandleAuthErrors = vi.fn(({ onError }) => onError?.(new Error("auth error"))); + +vi.mock("@/protoFleet/store", () => ({ + useAuthErrors: vi.fn(() => ({ + handleAuthErrors: mockHandleAuthErrors, + })), +})); + +function makeEntry(id: string) { + return create(ActivityEntrySchema, { + eventId: id, + eventCategory: "auth", + eventType: "login", + description: `Entry ${id}`, + result: "success", + actorType: "user", + }); +} + +function mockListResponse(entries: ReturnType[], nextPageToken = "", totalCount = 0) { + return create(ListActivitiesResponseSchema, { activities: entries, nextPageToken, totalCount }); +} + +describe("useActivity", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("fetches activities on mount with correct params", async () => { + const entries = [makeEntry("1"), makeEntry("2")]; + vi.mocked(activityClient.listActivities).mockResolvedValue(mockListResponse(entries, "", 2)); + + const { result } = renderHook(() => useActivity({ pageSize: 25 })); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(activityClient.listActivities).toHaveBeenCalledWith( + expect.objectContaining({ pageSize: 25, pageToken: "" }), + ); + expect(result.current.activities).toHaveLength(2); + expect(result.current.totalCount).toBe(2); + expect(result.current.hasMore).toBe(false); + }); + + it("loadMore appends next page of results", async () => { + const page1 = [makeEntry("1")]; + const page2 = [makeEntry("2")]; + + vi.mocked(activityClient.listActivities) + .mockResolvedValueOnce(mockListResponse(page1, "token-2", 2)) + .mockResolvedValueOnce(mockListResponse(page2, "", 2)); + + const { result } = renderHook(() => useActivity({ pageSize: 1 })); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.activities).toHaveLength(1); + expect(result.current.hasMore).toBe(true); + + await act(async () => { + result.current.loadMore(); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(activityClient.listActivities).toHaveBeenCalledTimes(2); + expect(activityClient.listActivities).toHaveBeenLastCalledWith(expect.objectContaining({ pageToken: "token-2" })); + expect(result.current.activities).toHaveLength(2); + expect(result.current.hasMore).toBe(false); + }); + + it("discards stale responses when a newer request starts", async () => { + let resolveFirst: (value: ReturnType) => void; + const firstPromise = new Promise>((r) => { + resolveFirst = r; + }); + + const staleEntries = [makeEntry("stale")]; + const freshEntries = [makeEntry("fresh")]; + + vi.mocked(activityClient.listActivities) + .mockReturnValueOnce(firstPromise as Promise) + .mockResolvedValueOnce(mockListResponse(freshEntries, "", 1)); + + const filter1 = create(ActivityFilterSchema, { searchText: "old" }); + const filter2 = create(ActivityFilterSchema, { searchText: "new" }); + + const { result, rerender } = renderHook(({ filter }) => useActivity({ filter }), { + initialProps: { filter: filter1 as ActivityFilter }, + }); + + // Trigger second fetch via filter change before first resolves + rerender({ filter: filter2 as ActivityFilter }); + + await waitFor(() => { + expect(activityClient.listActivities).toHaveBeenCalledTimes(2); + }); + + // Now resolve the stale first request + resolveFirst!(mockListResponse(staleEntries, "", 99)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Fresh result wins, stale result discarded + expect(result.current.activities[0].eventId).toBe("fresh"); + expect(result.current.totalCount).toBe(1); + }); + + it("refresh resets state and re-fetches from page 1", async () => { + const initialEntries = [makeEntry("1")]; + const refreshedEntries = [makeEntry("refreshed")]; + + vi.mocked(activityClient.listActivities) + .mockResolvedValueOnce(mockListResponse(initialEntries, "tok", 10)) + .mockResolvedValueOnce(mockListResponse(refreshedEntries, "", 5)); + + const { result } = renderHook(() => useActivity({})); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.totalCount).toBe(10); + expect(result.current.hasMore).toBe(true); + + await act(async () => { + result.current.refresh(); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(activityClient.listActivities).toHaveBeenCalledTimes(2); + expect(activityClient.listActivities).toHaveBeenLastCalledWith(expect.objectContaining({ pageToken: "" })); + expect(result.current.activities).toHaveLength(1); + expect(result.current.activities[0].eventId).toBe("refreshed"); + expect(result.current.totalCount).toBe(5); + expect(result.current.hasMore).toBe(false); + }); + + it("re-fetches when filter changes", async () => { + vi.mocked(activityClient.listActivities).mockResolvedValue(mockListResponse([], "", 0)); + + const filter1 = create(ActivityFilterSchema, { searchText: "alpha" }); + const filter2 = create(ActivityFilterSchema, { searchText: "beta" }); + + const { rerender } = renderHook(({ filter }) => useActivity({ filter }), { + initialProps: { filter: filter1 as ActivityFilter }, + }); + + await waitFor(() => { + expect(activityClient.listActivities).toHaveBeenCalledTimes(1); + }); + + rerender({ filter: filter2 as ActivityFilter }); + + await waitFor(() => { + expect(activityClient.listActivities).toHaveBeenCalledTimes(2); + }); + }); + + it("does not re-fetch when filter content is identical (deep equality)", async () => { + vi.mocked(activityClient.listActivities).mockResolvedValue(mockListResponse([], "", 0)); + + const filter1 = create(ActivityFilterSchema, { searchText: "same" }); + const filter2 = create(ActivityFilterSchema, { searchText: "same" }); + + const { rerender } = renderHook(({ filter }) => useActivity({ filter }), { + initialProps: { filter: filter1 as ActivityFilter }, + }); + + await waitFor(() => { + expect(activityClient.listActivities).toHaveBeenCalledTimes(1); + }); + + rerender({ filter: filter2 as ActivityFilter }); + + // Should still be 1 call -- no re-fetch for identical content + await waitFor(() => { + expect(activityClient.listActivities).toHaveBeenCalledTimes(1); + }); + }); + + it("handles auth errors and sets error state", async () => { + const testError = new Error("network failure"); + vi.mocked(activityClient.listActivities).mockRejectedValue(testError); + + const { result } = renderHook(() => useActivity({})); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(mockHandleAuthErrors).toHaveBeenCalledWith(expect.objectContaining({ error: testError })); + expect(result.current.error).toBeTruthy(); + expect(result.current.activities).toHaveLength(0); + }); +}); diff --git a/client/src/protoFleet/api/useActivity.ts b/client/src/protoFleet/api/useActivity.ts new file mode 100644 index 000000000..87561eafc --- /dev/null +++ b/client/src/protoFleet/api/useActivity.ts @@ -0,0 +1,159 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { equals } from "@bufbuild/protobuf"; +import { activityClient } from "@/protoFleet/api/clients"; +import { + type ActivityEntry, + type ActivityFilter, + ActivityFilterSchema, +} from "@/protoFleet/api/generated/activity/v1/activity_pb"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { useAuthErrors } from "@/protoFleet/store"; + +interface UseActivityParams { + filter?: ActivityFilter; + pageSize?: number; +} + +interface UseActivityResult { + activities: ActivityEntry[]; + totalCount: number; + isLoading: boolean; + error: string | null; + hasMore: boolean; + loadMore: () => void; + refresh: () => void; +} + +export function useActivity({ filter, pageSize = 50 }: UseActivityParams): UseActivityResult { + const { handleAuthErrors } = useAuthErrors(); + + const [activities, setActivities] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [hasMore, setHasMore] = useState(false); + const [pageToken, setPageToken] = useState(""); + + const requestIdRef = useRef(0); + + const fetchActivities = useCallback( + async (currentFilter: ActivityFilter | undefined, token: string, append: boolean) => { + const requestId = ++requestIdRef.current; + setIsLoading(true); + isLoadingRef.current = true; + setError(null); + + try { + const response = await activityClient.listActivities({ + filter: currentFilter, + pageSize, + pageToken: token, + }); + + if (requestId !== requestIdRef.current) return; + + const { activities: newActivities, nextPageToken, totalCount: responseTotalCount } = response; + + if (append) { + setActivities((prev) => [...prev, ...newActivities]); + } else { + setActivities(newActivities); + setTotalCount(responseTotalCount); + } + + setPageToken(nextPageToken); + pageTokenRef.current = nextPageToken; + setHasMore(nextPageToken !== ""); + hasMoreRef.current = nextPageToken !== ""; + } catch (err) { + if (requestId !== requestIdRef.current) return; + handleAuthErrors({ + error: err, + onError: (e) => { + setError(getErrorMessage(e, "Failed to load activities")); + }, + }); + } finally { + if (requestId === requestIdRef.current) { + setIsLoading(false); + isLoadingRef.current = false; + } + } + }, + [pageSize, handleAuthErrors], + ); + + // Ref-based stability (same pattern as useFleet.ts) + const fetchRef = useRef(fetchActivities); + useEffect(() => { + fetchRef.current = fetchActivities; + }, [fetchActivities]); + + const filterRef = useRef(filter); + useEffect(() => { + filterRef.current = filter; + }, [filter]); + + const pageTokenRef = useRef(pageToken); + useEffect(() => { + pageTokenRef.current = pageToken; + }, [pageToken]); + + const isLoadingRef = useRef(isLoading); + useEffect(() => { + isLoadingRef.current = isLoading; + }, [isLoading]); + + const hasMoreRef = useRef(hasMore); + useEffect(() => { + hasMoreRef.current = hasMore; + }, [hasMore]); + + const loadMore = useCallback(() => { + if (hasMoreRef.current && !isLoadingRef.current) { + fetchRef.current(filterRef.current, pageTokenRef.current, true); + } + }, []); + + const refresh = useCallback(() => { + if (isLoadingRef.current) return; + setActivities([]); + setPageToken(""); + pageTokenRef.current = ""; + setHasMore(false); + hasMoreRef.current = false; + setTotalCount(0); + fetchRef.current(filterRef.current, "", false); + }, []); + + // Re-fetch when filter or pageSize changes (deep equality for filter) + const previousFilterRef = useRef(undefined); + const previousPageSizeRef = useRef(pageSize); + const hasLoadedRef = useRef(false); + + useEffect(() => { + const filtersEqual = + previousFilterRef.current === filter || + (previousFilterRef.current !== undefined && + filter !== undefined && + equals(ActivityFilterSchema, previousFilterRef.current, filter)); + const pageSizeChanged = previousPageSizeRef.current !== pageSize; + + if (hasLoadedRef.current && filtersEqual && !pageSizeChanged) return; + + previousFilterRef.current = filter; + previousPageSizeRef.current = pageSize; + hasLoadedRef.current = true; + + setActivities([]); + setPageToken(""); + pageTokenRef.current = ""; + setHasMore(false); + hasMoreRef.current = false; + setTotalCount(0); + + void fetchRef.current(filter, "", false); + }, [filter, pageSize]); + + return { activities, totalCount, isLoading, error, hasMore, loadMore, refresh }; +} diff --git a/client/src/protoFleet/api/useActivityFilterOptions.ts b/client/src/protoFleet/api/useActivityFilterOptions.ts new file mode 100644 index 000000000..dc96b8e6f --- /dev/null +++ b/client/src/protoFleet/api/useActivityFilterOptions.ts @@ -0,0 +1,49 @@ +import { useCallback, useEffect, useState } from "react"; +import { activityClient } from "@/protoFleet/api/clients"; +import type { EventTypeOption, UserOption } from "@/protoFleet/api/generated/activity/v1/activity_pb"; +import { useAuthErrors } from "@/protoFleet/store"; + +interface UseActivityFilterOptionsResult { + eventTypes: EventTypeOption[]; + scopeTypes: string[]; + users: UserOption[]; + isLoading: boolean; + error: string | null; +} + +export function useActivityFilterOptions(): UseActivityFilterOptionsResult { + const { handleAuthErrors } = useAuthErrors(); + + const [eventTypes, setEventTypes] = useState([]); + const [scopeTypes, setScopeTypes] = useState([]); + const [users, setUsers] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchFilterOptions = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const response = await activityClient.listActivityFilterOptions({}); + setEventTypes(response.eventTypes); + setScopeTypes(response.scopeTypes); + setUsers(response.users); + } catch (error) { + handleAuthErrors({ + error, + onError: (err) => { + const message = err instanceof Error ? err.message : String(err); + setError(message); + }, + }); + } finally { + setIsLoading(false); + } + }, [handleAuthErrors]); + + useEffect(() => { + void fetchFilterOptions(); + }, [fetchFilterOptions]); + + return { eventTypes, scopeTypes, users, isLoading, error }; +} diff --git a/client/src/protoFleet/api/useApiKeys.ts b/client/src/protoFleet/api/useApiKeys.ts new file mode 100644 index 000000000..59df85953 --- /dev/null +++ b/client/src/protoFleet/api/useApiKeys.ts @@ -0,0 +1,120 @@ +import { useCallback } from "react"; + +import { apiKeyClient } from "@/protoFleet/api/clients"; +import type { ApiKeyInfo } from "@/protoFleet/api/generated/apikey/v1/apikey_pb"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { useAuthErrors } from "@/protoFleet/store"; + +export interface ApiKeyItem { + keyId: string; + name: string; + prefix: string; + createdAt: Date | null; + expiresAt: Date | null; + lastUsedAt: Date | null; + createdBy: string; +} + +interface CreateApiKeyProps { + name: string; + expiresAt?: Date; +} + +interface ListApiKeysProps { + onSuccess?: (keys: ApiKeyItem[]) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface RevokeApiKeyProps { + keyId: string; + onSuccess?: () => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +function toApiKeyItem(info: ApiKeyInfo): ApiKeyItem { + return { + keyId: info.keyId, + name: info.name, + prefix: info.prefix, + createdAt: info.createdAt && info.createdAt.seconds > 0 ? new Date(Number(info.createdAt.seconds) * 1000) : null, + expiresAt: info.expiresAt && info.expiresAt.seconds > 0 ? new Date(Number(info.expiresAt.seconds) * 1000) : null, + lastUsedAt: + info.lastUsedAt && info.lastUsedAt.seconds > 0 ? new Date(Number(info.lastUsedAt.seconds) * 1000) : null, + createdBy: info.createdBy, + }; +} + +const useApiKeys = () => { + const { handleAuthErrors } = useAuthErrors(); + + const createApiKey = useCallback( + async ({ name, expiresAt }: CreateApiKeyProps): Promise => { + try { + const response = await apiKeyClient.createApiKey({ + name, + expiresAt: expiresAt ? { seconds: BigInt(Math.floor(expiresAt.getTime() / 1000)), nanos: 0 } : undefined, + }); + + if (!response.info) { + throw new Error("Received an unexpected response from the server. Please try again."); + } + + return response.apiKey; + } catch (err) { + handleAuthErrors({ error: err }); + throw err instanceof Error ? err : new Error(String(err)); + } + }, + [handleAuthErrors], + ); + + const listApiKeys = useCallback( + async ({ onSuccess, onError, onFinally }: ListApiKeysProps) => { + await apiKeyClient + .listApiKeys({}) + .then((response) => { + onSuccess?.(response.apiKeys.map(toApiKeyItem)); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }) + .finally(() => { + onFinally?.(); + }); + }, + [handleAuthErrors], + ); + + const revokeApiKey = useCallback( + async ({ keyId, onSuccess, onError, onFinally }: RevokeApiKeyProps) => { + await apiKeyClient + .revokeApiKey({ keyId }) + .then(() => { + onSuccess?.(); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }) + .finally(() => { + onFinally?.(); + }); + }, + [handleAuthErrors], + ); + + return { createApiKey, listApiKeys, revokeApiKey }; +}; + +export { useApiKeys }; diff --git a/client/src/protoFleet/api/useAuth.ts b/client/src/protoFleet/api/useAuth.ts new file mode 100644 index 000000000..0f04997be --- /dev/null +++ b/client/src/protoFleet/api/useAuth.ts @@ -0,0 +1,137 @@ +import { useCallback, useEffect, useState } from "react"; + +import { authClient, onboardingClient } from "@/protoFleet/api/clients"; +import { UpdatePasswordRequest, UpdateUsernameRequest } from "@/protoFleet/api/generated/auth/v1/auth_pb"; +import { CreateAdminLoginRequest } from "@/protoFleet/api/generated/onboarding/v1/onboarding_pb"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { useAuthErrors, useSetUsername } from "@/protoFleet/store"; + +interface SetPasswordProps { + onError?: (message: string) => void; + onFinally?: () => void; + onSuccess?: () => void; + setPasswordRequest: CreateAdminLoginRequest; +} +interface UpdatePasswordProps { + onError?: (message: string) => void; + onFinally?: () => void; + onSuccess?: () => void; + currentPassword: UpdatePasswordRequest["currentPassword"]; + newPassword: UpdatePasswordRequest["newPassword"]; +} + +interface UpdateUsernameProps { + username: UpdateUsernameRequest["username"]; + onSuccess?: () => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +const useAuth = () => { + const setUsername = useSetUsername(); + const { handleAuthErrors } = useAuthErrors(); + const [passwordLastUpdatedAt, setPasswordLastUpdatedAt] = useState(null); + + const setPassword = useCallback( + async ({ setPasswordRequest, onSuccess, onError, onFinally }: SetPasswordProps) => { + await onboardingClient + .createAdminLogin(setPasswordRequest) + .then(() => { + setUsername(setPasswordRequest.username); + onSuccess?.(); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }) + .finally(() => { + onFinally?.(); + }); + }, + [setUsername, handleAuthErrors], + ); + + const fetchLastUpdatedPasswordDate = useCallback(async () => { + try { + const response = await authClient.getUserAuditInfo({}); + + if (response.info?.passwordUpdatedAt && response.info.passwordUpdatedAt.seconds > 0) { + const seconds = Number(response.info.passwordUpdatedAt.seconds); + const date = new Date(seconds * 1000); + setPasswordLastUpdatedAt(date); + } else { + setPasswordLastUpdatedAt(null); + } + } catch (error) { + handleAuthErrors({ + error, + onError: () => { + console.error("Error fetching last updated password date:", error); + }, + }); + } + }, [handleAuthErrors]); + + const updatePassword = useCallback( + async ({ currentPassword, newPassword, onSuccess, onError, onFinally }: UpdatePasswordProps) => { + await authClient + .updatePassword({ currentPassword, newPassword }) + .then(() => { + onSuccess?.(); + fetchLastUpdatedPasswordDate(); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }) + .finally(() => { + onFinally?.(); + }); + }, + [fetchLastUpdatedPasswordDate, handleAuthErrors], + ); + + const updateUsername = useCallback( + async ({ username, onSuccess, onError, onFinally }: UpdateUsernameProps) => { + await authClient + .updateUsername({ username }) + .then(() => { + setUsername(username); + onSuccess?.(); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }) + .finally(() => { + onFinally?.(); + }); + }, + [setUsername, handleAuthErrors], + ); + + useEffect(() => { + fetchLastUpdatedPasswordDate(); + }, [fetchLastUpdatedPasswordDate]); + + return { + setPassword, + updatePassword, + updateUsername, + passwordLastUpdatedAt, + }; +}; + +export { useAuth }; diff --git a/client/src/protoFleet/api/useAuthNeededMiners.test.ts b/client/src/protoFleet/api/useAuthNeededMiners.test.ts new file mode 100644 index 000000000..392befade --- /dev/null +++ b/client/src/protoFleet/api/useAuthNeededMiners.test.ts @@ -0,0 +1,143 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import useAuthNeededMiners from "./useAuthNeededMiners"; +import useFleet from "./useFleet"; +import { + MinerStateSnapshotSchema, + PairingStatus, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +vi.mock("./useFleet"); + +describe("useAuthNeededMiners", () => { + const mockUseFleetReturn = { + minerIds: [], + miners: {}, + totalMiners: 0, + hasMore: false, + isLoading: false, + hasInitialLoadCompleted: true, + loadMore: vi.fn(), + currentPage: 0, + hasPreviousPage: false, + goToNextPage: vi.fn(), + goToPrevPage: vi.fn(), + refetch: vi.fn(), + refreshCurrentPage: vi.fn(), + updateMinerWorkerName: vi.fn(), + availableModels: [], + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useFleet).mockReturnValue(mockUseFleetReturn); + }); + + it("calls useFleet with default options", () => { + renderHook(() => useAuthNeededMiners()); + + expect(useFleet).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + + pageSize: 100, + pairingStatuses: [PairingStatus.AUTHENTICATION_NEEDED], + }), + ); + }); + + it("calls useFleet with custom pageSize", () => { + renderHook(() => useAuthNeededMiners({ pageSize: 50 })); + + expect(useFleet).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + + pageSize: 50, + pairingStatuses: [PairingStatus.AUTHENTICATION_NEEDED], + }), + ); + }); + + it("returns the same result as useFleet", () => { + const { result } = renderHook(() => useAuthNeededMiners()); + + expect(result.current).toEqual(mockUseFleetReturn); + }); + + it("filters for AUTHENTICATION_NEEDED pairing status only", () => { + renderHook(() => useAuthNeededMiners()); + + const callArgs = vi.mocked(useFleet).mock.calls[0]?.[0]; + expect(callArgs).toBeDefined(); + expect(callArgs?.pairingStatuses).toEqual([PairingStatus.AUTHENTICATION_NEEDED]); + }); + + describe("pagination", () => { + it("exposes hasMore flag for pagination", () => { + vi.mocked(useFleet).mockReturnValue({ + ...mockUseFleetReturn, + hasMore: true, + }); + + const { result } = renderHook(() => useAuthNeededMiners()); + + expect(result.current.hasMore).toBe(true); + }); + + it("exposes isLoading flag", () => { + vi.mocked(useFleet).mockReturnValue({ + ...mockUseFleetReturn, + isLoading: true, + }); + + const { result } = renderHook(() => useAuthNeededMiners()); + + expect(result.current.isLoading).toBe(true); + }); + + it("exposes loadMore function for pagination", () => { + const mockLoadMore = vi.fn(); + vi.mocked(useFleet).mockReturnValue({ + ...mockUseFleetReturn, + loadMore: mockLoadMore, + }); + + const { result } = renderHook(() => useAuthNeededMiners()); + + expect(result.current.loadMore).toBe(mockLoadMore); + expect(typeof result.current.loadMore).toBe("function"); + }); + + it("exposes totalMiners count", () => { + vi.mocked(useFleet).mockReturnValue({ + ...mockUseFleetReturn, + totalMiners: 42, + }); + + const { result } = renderHook(() => useAuthNeededMiners()); + + expect(result.current.totalMiners).toBe(42); + }); + + it("exposes miners map for local scope", () => { + const mockMiners = { + "miner-1": create(MinerStateSnapshotSchema, { + deviceIdentifier: "miner-1", + }), + "miner-2": create(MinerStateSnapshotSchema, { + deviceIdentifier: "miner-2", + }), + }; + vi.mocked(useFleet).mockReturnValue({ + ...mockUseFleetReturn, + miners: mockMiners, + }); + + const { result } = renderHook(() => useAuthNeededMiners()); + + expect(result.current.miners).toEqual(mockMiners); + }); + }); +}); diff --git a/client/src/protoFleet/api/useAuthNeededMiners.ts b/client/src/protoFleet/api/useAuthNeededMiners.ts new file mode 100644 index 000000000..d478400a6 --- /dev/null +++ b/client/src/protoFleet/api/useAuthNeededMiners.ts @@ -0,0 +1,77 @@ +import useFleet from "./useFleet"; +import { MinerStateSnapshot, PairingStatus } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { MinerListFilter } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +type UseAuthNeededMinersOptions = { + pageSize?: number; + filter?: MinerListFilter; + enabled?: boolean; +}; + +type UseAuthNeededMinersReturn = { + /** Array of miner device identifiers */ + minerIds: string[]; + /** Map of miner device identifier to miner state snapshot (only for local scope) */ + miners: Record; + /** Total number of miners matching the filter */ + totalMiners: number; + /** Whether there are more miners to load */ + hasMore: boolean; + /** Whether the hook is currently loading data */ + isLoading: boolean; + /** Whether the initial load has completed */ + hasInitialLoadCompleted: boolean; + /** Load the next page of miners */ + loadMore: () => void; + /** Refetch the miner list from the beginning */ + refetch: () => void; + /** Available models for filter dropdown */ + availableModels: string[]; +}; + +/** + * Hook for fetching miners that require authentication credentials. + * This is a convenience wrapper around useFleet that filters for devices + * with AUTHENTICATION_NEEDED pairing status. + * + * These are devices that have been discovered and require user credentials + * to complete the pairing process. + * + * Uses local scope to avoid conflicting with the main fleet view's global state. + * This allows CompleteSetup and AuthenticateMiners to fetch auth-needed miners + * without affecting the MinerList component's data. + * + * @param options - Configuration options for the hook + * @param options.pageSize - Number of devices to fetch per page (default: 100) + * @param options.filter - Optional filter to apply to the auth-needed miners (e.g., status, tags, etc.) + * @returns Object containing miner data and pagination controls + * + * @example + * ```tsx + * const { + * minerIds, + * miners, + * totalMiners, + * hasMore, + * isLoading, + * loadMore + * } = useAuthNeededMiners({ pageSize: 50 }); + * + * // Load more miners when user scrolls + * if (hasMore && !isLoading) { + * loadMore(); + * } + * ``` + */ +const useAuthNeededMiners = (options: UseAuthNeededMinersOptions = {}): UseAuthNeededMinersReturn => { + const { pageSize = 100, filter, enabled = true } = options; + + return useFleet({ + enabled, + pageSize, + filter, + pairingStatuses: [PairingStatus.AUTHENTICATION_NEEDED], + }) as UseAuthNeededMinersReturn; +}; + +export default useAuthNeededMiners; diff --git a/client/src/protoFleet/api/useComponentErrors.test.ts b/client/src/protoFleet/api/useComponentErrors.test.ts new file mode 100644 index 000000000..55df01acb --- /dev/null +++ b/client/src/protoFleet/api/useComponentErrors.test.ts @@ -0,0 +1,247 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import { errorQueryClient } from "./clients"; +import { useComponentErrors } from "./useComponentErrors"; +import { + ComponentErrorSchema, + ComponentErrorsSchema, + ComponentType, + ErrorMessageSchema, + QueryResponseSchema, +} from "@/protoFleet/api/generated/errors/v1/errors_pb"; + +vi.mock("./clients", () => ({ + errorQueryClient: { + query: vi.fn(), + }, +})); + +vi.mock("@/protoFleet/store", () => ({ + useFleetStore: vi.fn((selector) => + selector({ + auth: { authLoading: false }, + }), + ), + useAuthErrors: vi.fn(() => ({ + handleAuthErrors: vi.fn(({ onError }) => onError), + })), +})); + +describe("useComponentErrors", () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Default mock for query - empty response + vi.mocked(errorQueryClient.query).mockResolvedValue( + create(QueryResponseSchema, { + result: { + case: "components", + value: create(ComponentErrorsSchema, { items: [] }), + }, + }), + ); + }); + + describe("device counting logic", () => { + it("counts unique devices, not component instances (THE BUG FIX)", async () => { + // Device A has 3 fans with errors (fan_0, fan_1, fan_2) + // This should count as 1 device, not 3 + const mockResponse = create(QueryResponseSchema, { + result: { + case: "components", + value: create(ComponentErrorsSchema, { + items: [ + create(ComponentErrorSchema, { + componentId: "device-a_fan_0", + componentType: ComponentType.FAN, + deviceIdentifier: "device-a", + errors: [create(ErrorMessageSchema, { errorId: "err-1" })], + }), + create(ComponentErrorSchema, { + componentId: "device-a_fan_1", + componentType: ComponentType.FAN, + deviceIdentifier: "device-a", + errors: [create(ErrorMessageSchema, { errorId: "err-2" })], + }), + create(ComponentErrorSchema, { + componentId: "device-a_fan_2", + componentType: ComponentType.FAN, + deviceIdentifier: "device-a", + errors: [create(ErrorMessageSchema, { errorId: "err-3" })], + }), + ], + }), + }, + }); + + vi.mocked(errorQueryClient.query).mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useComponentErrors()); + + await waitFor(() => { + expect(result.current.hasLoaded).toBe(true); + }); + + // Should count 1 device with fan errors, not 3 + expect(result.current.fanErrors).toBe(1); + }); + + it("counts each unique device separately", async () => { + // 3 different devices, each with 1 fan error + const mockResponse = create(QueryResponseSchema, { + result: { + case: "components", + value: create(ComponentErrorsSchema, { + items: [ + create(ComponentErrorSchema, { + componentType: ComponentType.FAN, + deviceIdentifier: "device-a", + errors: [create(ErrorMessageSchema, { errorId: "err-1" })], + }), + create(ComponentErrorSchema, { + componentType: ComponentType.FAN, + deviceIdentifier: "device-b", + errors: [create(ErrorMessageSchema, { errorId: "err-2" })], + }), + create(ComponentErrorSchema, { + componentType: ComponentType.FAN, + deviceIdentifier: "device-c", + errors: [create(ErrorMessageSchema, { errorId: "err-3" })], + }), + ], + }), + }, + }); + + vi.mocked(errorQueryClient.query).mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useComponentErrors()); + + await waitFor(() => { + expect(result.current.hasLoaded).toBe(true); + }); + + expect(result.current.fanErrors).toBe(3); + }); + + it("handles mix of devices with multiple components correctly (regression test)", async () => { + // Device A: fan_0, fan_1, fan_2, fan_3 (4 fans) + // Device B: fan_0, fan_1, fan_2 (3 fans) + // Device C: fan_0, fan_1, fan_2, fan_3 (4 fans) + // Total: 11 component entries, but only 3 unique devices + const items = [ + // Device A - 4 fans + ...["fan_0", "fan_1", "fan_2", "fan_3"].map((fan, i) => + create(ComponentErrorSchema, { + componentId: `device-a_${fan}`, + componentType: ComponentType.FAN, + deviceIdentifier: "device-a", + errors: [create(ErrorMessageSchema, { errorId: `err-a-${i}` })], + }), + ), + // Device B - 3 fans + ...["fan_0", "fan_1", "fan_2"].map((fan, i) => + create(ComponentErrorSchema, { + componentId: `device-b_${fan}`, + componentType: ComponentType.FAN, + deviceIdentifier: "device-b", + errors: [create(ErrorMessageSchema, { errorId: `err-b-${i}` })], + }), + ), + // Device C - 4 fans + ...["fan_0", "fan_1", "fan_2", "fan_3"].map((fan, i) => + create(ComponentErrorSchema, { + componentId: `device-c_${fan}`, + componentType: ComponentType.FAN, + deviceIdentifier: "device-c", + errors: [create(ErrorMessageSchema, { errorId: `err-c-${i}` })], + }), + ), + ]; + + const mockResponse = create(QueryResponseSchema, { + result: { + case: "components", + value: create(ComponentErrorsSchema, { items }), + }, + }); + + vi.mocked(errorQueryClient.query).mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useComponentErrors()); + + await waitFor(() => { + expect(result.current.hasLoaded).toBe(true); + }); + + // Should count 3 devices, not 11 component instances + expect(result.current.fanErrors).toBe(3); + }); + + it("tracks each component type independently", async () => { + // Device A has both fan and hashboard errors + const mockResponse = create(QueryResponseSchema, { + result: { + case: "components", + value: create(ComponentErrorsSchema, { + items: [ + create(ComponentErrorSchema, { + componentType: ComponentType.FAN, + deviceIdentifier: "device-a", + errors: [create(ErrorMessageSchema, { errorId: "err-fan" })], + }), + create(ComponentErrorSchema, { + componentType: ComponentType.HASH_BOARD, + deviceIdentifier: "device-a", + errors: [create(ErrorMessageSchema, { errorId: "err-hb" })], + }), + ], + }), + }, + }); + + vi.mocked(errorQueryClient.query).mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useComponentErrors()); + + await waitFor(() => { + expect(result.current.hasLoaded).toBe(true); + }); + + expect(result.current.fanErrors).toBe(1); + expect(result.current.hashboardErrors).toBe(1); + }); + }); + + describe("hook behavior", () => { + it("returns zero counts for empty response", async () => { + const { result } = renderHook(() => useComponentErrors()); + + await waitFor(() => { + expect(result.current.hasLoaded).toBe(true); + }); + + expect(result.current.fanErrors).toBe(0); + expect(result.current.hashboardErrors).toBe(0); + expect(result.current.psuErrors).toBe(0); + expect(result.current.controlBoardErrors).toBe(0); + }); + + it("returns isLoading true initially", () => { + const { result } = renderHook(() => useComponentErrors()); + + expect(result.current.isLoading).toBe(true); + }); + + it("sets hasLoaded after successful fetch", async () => { + const { result } = renderHook(() => useComponentErrors()); + + expect(result.current.hasLoaded).toBe(false); + + await waitFor(() => { + expect(result.current.hasLoaded).toBe(true); + }); + }); + }); +}); diff --git a/client/src/protoFleet/api/useComponentErrors.ts b/client/src/protoFleet/api/useComponentErrors.ts new file mode 100644 index 000000000..6421ed64f --- /dev/null +++ b/client/src/protoFleet/api/useComponentErrors.ts @@ -0,0 +1,269 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { create } from "@bufbuild/protobuf"; +import { errorQueryClient } from "@/protoFleet/api/clients"; +import { + type ComponentError, + ComponentType, + QueryRequestSchema, + ResultView, + type Summary, +} from "@/protoFleet/api/generated/errors/v1/errors_pb"; +import { useAuthErrors, useFleetStore } from "@/protoFleet/store"; + +interface ComponentErrorCounts { + controlBoardErrors: number; + fanErrors: number; + hashboardErrors: number; + psuErrors: number; +} + +interface UseComponentErrorsReturn extends ComponentErrorCounts { + isLoading: boolean; + hasLoaded: boolean; + error: Error | null; + refetch: () => Promise; +} + +interface UseComponentErrorsOptions { + /** Optional device identifiers to scope errors to specific devices (e.g., a group's members) */ + deviceIdentifiers?: string[]; + /** Optional polling interval in milliseconds */ + pollIntervalMs?: number; +} + +/** + * Hook to fetch component error counts. + * Manages its own local state — no dashboard store dependency. + * Supports optional polling for periodic refresh. + */ +export const useComponentErrors = (options?: UseComponentErrorsOptions): UseComponentErrorsReturn => { + const deviceIdentifiers = options?.deviceIdentifiers; + const isEmptyScope = deviceIdentifiers !== undefined && deviceIdentifiers.length === 0; + const deviceIdentifiersKey = deviceIdentifiers === undefined ? "__undefined__" : deviceIdentifiers.join(","); + + const authLoading = useFleetStore((state) => state.auth.authLoading); + const { handleAuthErrors } = useAuthErrors(); + + // Ref so fetchComponentErrors reads latest deviceIdentifiers without needing it as a dependency + const deviceIdentifiersRef = useRef(deviceIdentifiers); + deviceIdentifiersRef.current = deviceIdentifiers; + + // Local state for error counts + const [counts, setCounts] = useState>>({}); + const [isLoading, setIsLoading] = useState(true); + const [hasLoaded, setHasLoaded] = useState(false); + const [error, setError] = useState(null); + + const requestIdRef = useRef(0); + const hasLoadedRef = useRef(false); + + // Reset on scope change — invalidate in-flight requests so stale responses can't land + const prevScopeRef = useRef(deviceIdentifiersKey); + if (prevScopeRef.current !== deviceIdentifiersKey) { + prevScopeRef.current = deviceIdentifiersKey; + ++requestIdRef.current; + hasLoadedRef.current = false; + setHasLoaded(false); + setCounts({}); + } + + const errorCounts: ComponentErrorCounts = { + controlBoardErrors: counts[ComponentType.CONTROL_BOARD] || 0, + fanErrors: counts[ComponentType.FAN] || 0, + hashboardErrors: counts[ComponentType.HASH_BOARD] || 0, + psuErrors: counts[ComponentType.PSU] || 0, + }; + + const fetchComponentErrors = useCallback(async () => { + if (isEmptyScope) { + ++requestIdRef.current; + setCounts({}); + setIsLoading(false); + return; + } + + const thisRequestId = ++requestIdRef.current; + + if (!hasLoadedRef.current) { + setIsLoading(true); + } + setError(null); + + try { + const currentDeviceIdentifiers = deviceIdentifiersRef.current; + const request = create(QueryRequestSchema, { + resultView: ResultView.COMPONENT, + filter: { + simple: { + ...(currentDeviceIdentifiers && + currentDeviceIdentifiers.length > 0 && { deviceIdentifiers: currentDeviceIdentifiers }), + }, + includeClosed: false, + }, + pageSize: 1000, + }); + + const response = await errorQueryClient.query(request); + + if (thisRequestId !== requestIdRef.current) return; + + if (response.result?.case === "components" && response.result.value) { + const newCounts = processComponentErrorCounts(response.result.value.items); + setCounts(newCounts); + } else { + setCounts({}); + } + hasLoadedRef.current = true; + setHasLoaded(true); + } catch (err) { + if (thisRequestId !== requestIdRef.current) return; + handleAuthErrors({ + error: err, + onError: (error) => { + console.error("Error fetching component errors:", error); + setError(error instanceof Error ? error : new Error("Failed to fetch component errors")); + }, + }); + } finally { + if (thisRequestId === requestIdRef.current) { + setIsLoading(false); + } + } + }, [handleAuthErrors, isEmptyScope]); + + // Initial fetch + refetch on scope change + useEffect(() => { + if (authLoading) return; + fetchComponentErrors(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [authLoading, deviceIdentifiersKey]); + + // Polling + useEffect(() => { + if (!options?.pollIntervalMs || authLoading) return; + + const intervalId = setInterval(() => { + void fetchComponentErrors(); + }, options.pollIntervalMs); + + return () => clearInterval(intervalId); + }, [options?.pollIntervalMs, authLoading, fetchComponentErrors]); + + return { + ...errorCounts, + isLoading, + hasLoaded, + error, + refetch: fetchComponentErrors, + }; +}; + +/** Count unique devices per component type from query response */ +function processComponentErrorCounts(components: ComponentError[]): Partial> { + const devicesByComponentType: Partial>> = {}; + + components.forEach((component) => { + if ( + component.componentType !== undefined && + component.deviceIdentifier && + component.errors && + component.errors.length > 0 + ) { + if (!devicesByComponentType[component.componentType]) { + devicesByComponentType[component.componentType] = new Set(); + } + devicesByComponentType[component.componentType]!.add(component.deviceIdentifier); + } + }); + + const counts: Partial> = {}; + Object.entries(devicesByComponentType).forEach(([type, devices]) => { + counts[Number(type) as ComponentType] = devices.size; + }); + return counts; +} + +// Additional types and hook for fetching specific component error details +interface ComponentErrorDetailResult { + summary?: Summary; + componentError?: ComponentError; + loading: boolean; + errorMessage?: string; +} + +/** + * Hook to fetch a specific component's errors and summary from the errors API. + * This is used when navigating to a component view in the StatusModal. + * @param deviceIdentifier - UUID of the device + * @param componentId - Full component ID (e.g., "1_hashboard_0") + * @param enabled - Whether to fetch (default true) + */ +export const useComponentErrorDetail = ( + deviceIdentifier: string | undefined, + componentId: string | undefined, + enabled = true, +): ComponentErrorDetailResult => { + const [result, setResult] = useState({ + loading: false, + }); + + const { handleAuthErrors } = useAuthErrors(); + + useEffect(() => { + if (!deviceIdentifier || !componentId || !enabled) { + return; + } + + const fetchComponentDetail = async () => { + setResult((prev) => ({ ...prev, loading: true })); + + try { + // Create query request for component view + const request = create(QueryRequestSchema, { + resultView: ResultView.COMPONENT, + filter: { + simple: { + deviceIdentifiers: [deviceIdentifier], + componentIds: [componentId], + }, + }, + pageSize: 100, // Increase to ensure we get all components + }); + + const response = await errorQueryClient.query(request); + + // Extract component error from response + if (response.result?.case === "components" && response.result.value?.items?.length > 0) { + const componentError = response.result.value.items[0]; + + setResult({ + summary: componentError.summary, + componentError, + loading: false, + }); + } else { + setResult({ + summary: undefined, + componentError: undefined, + loading: false, + }); + } + } catch (err) { + handleAuthErrors({ + error: err, + onError: (error) => { + console.error("Failed to fetch component error detail:", error); + setResult({ + loading: false, + errorMessage: error instanceof Error ? error.message : "Failed to fetch component errors", + }); + }, + }); + } + }; + + fetchComponentDetail(); + }, [deviceIdentifier, componentId, enabled, handleAuthErrors]); + + return result; +}; diff --git a/client/src/protoFleet/api/useDeviceErrors.ts b/client/src/protoFleet/api/useDeviceErrors.ts new file mode 100644 index 000000000..810edf185 --- /dev/null +++ b/client/src/protoFleet/api/useDeviceErrors.ts @@ -0,0 +1,160 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { create } from "@bufbuild/protobuf"; +import { errorQueryClient } from "@/protoFleet/api/clients"; +import { + type DeviceError, + type ErrorMessage, + QueryRequestSchema, + ResultView, +} from "@/protoFleet/api/generated/errors/v1/errors_pb"; +import { useAuthErrors } from "@/protoFleet/store"; + +interface UseDeviceErrorsReturn { + errorsByDevice: Record; + isLoading: boolean; + /** True once at least one successful fetch has completed. */ + hasLoaded: boolean; + error: Error | null; + refetch: () => Promise; +} + +/** + * Hook to fetch device errors for a list of miner IDs. + * Returns errors grouped by device ID. All state is local to this hook. + */ +export const useDeviceErrors = (deviceIds: string[]): UseDeviceErrorsReturn => { + const { handleAuthErrors } = useAuthErrors(); + const [errorsByDevice, setErrorsByDevice] = useState>({}); + const [isLoading, setIsLoading] = useState(false); + const [hasLoaded, setHasLoaded] = useState(false); + const [error, setError] = useState(null); + // Ref mirror of hasLoaded — used inside fetchDeviceErrors to gate isLoading + const hasLoadedRef = useRef(false); + + // Keep a ref to deviceIds so refetch() always uses the latest value + const deviceIdsRef = useRef(deviceIds); + deviceIdsRef.current = deviceIds; + + // Request sequencing — ignore responses from stale requests + const requestIdRef = useRef(0); + + const fetchDeviceErrors = useCallback( + async (ids: string[]) => { + // Bump counter before any early return so in-flight requests for the + // previous device set are discarded when they resolve. + const thisRequestId = ++requestIdRef.current; + + if (!ids || ids.length === 0) { + setErrorsByDevice({}); + setIsLoading(false); + return; + } + + // Only show loading state on initial fetch, not on poll refreshes + if (!hasLoadedRef.current) { + setIsLoading(true); + } + setError(null); + + try { + const request = create(QueryRequestSchema, { + resultView: ResultView.DEVICE, + filter: { + simple: { + deviceIdentifiers: ids, + }, + includeClosed: false, + }, + pageSize: 1000, + }); + + const response = await errorQueryClient.query(request); + + // Discard if a newer request has been issued since this one started + if (thisRequestId !== requestIdRef.current) return; + + const byDevice: Record = {}; + + if (response.result?.case === "devices" && response.result.value) { + const deviceErrors = response.result.value.items; + + deviceErrors.forEach((deviceError: DeviceError) => { + const deviceId = deviceError.deviceIdentifier; + if (deviceId && deviceError.errors) { + byDevice[deviceId] = [...deviceError.errors]; + } + }); + } + + // Only update state if error data actually changed — avoids unnecessary + // re-renders of MinerList/deviceItems on every poll when errors are unchanged. + setErrorsByDevice((prev) => { + const prevKeys = Object.keys(prev); + const nextKeys = Object.keys(byDevice); + if (prevKeys.length !== nextKeys.length) return byDevice; + for (const key of nextKeys) { + const prevErrors = prev[key]; + const nextErrors = byDevice[key]; + if (!prevErrors || prevErrors.length !== nextErrors.length) return byDevice; + // Compare error IDs to catch type/content changes at the same count + for (let i = 0; i < nextErrors.length; i++) { + if (prevErrors[i].errorId !== nextErrors[i].errorId) return byDevice; + } + } + return prev; + }); + + hasLoadedRef.current = true; + setHasLoaded(true); + } catch (err) { + // Discard errors from stale requests + if (thisRequestId !== requestIdRef.current) return; + + handleAuthErrors({ + error: err, + onError: (error) => { + console.error("Error fetching device errors:", error); + setError(error instanceof Error ? error : new Error("Failed to fetch device errors")); + }, + }); + } finally { + if (thisRequestId === requestIdRef.current) { + setIsLoading(false); + } + } + }, + [handleAuthErrors], + ); + + // Track the previous deviceIds to detect meaningful changes (not just poll refreshes) + const prevDeviceIdsRef = useRef(deviceIds); + + // Fetch errors when device IDs change + useEffect(() => { + // Reset loading state when the device list actually changes (pagination/filter), + // but not on the same list (poll refreshes are handled by refetch which skips loading). + const prevIds = prevDeviceIdsRef.current; + const idsChanged = prevIds.length !== deviceIds.length || deviceIds.some((id, i) => id !== prevIds[i]); + if (idsChanged) { + hasLoadedRef.current = false; + setHasLoaded(false); + } + prevDeviceIdsRef.current = deviceIds; + + fetchDeviceErrors(deviceIds); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [deviceIds]); + + // Stable refetch that uses the latest deviceIds + const refetch = useCallback(async () => { + await fetchDeviceErrors(deviceIdsRef.current); + }, [fetchDeviceErrors]); + + return { + errorsByDevice, + isLoading, + hasLoaded, + error, + refetch, + }; +}; diff --git a/client/src/protoFleet/api/useDeviceSetStateCounts.test.ts b/client/src/protoFleet/api/useDeviceSetStateCounts.test.ts new file mode 100644 index 000000000..a111056df --- /dev/null +++ b/client/src/protoFleet/api/useDeviceSetStateCounts.test.ts @@ -0,0 +1,132 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { deviceSetClient } from "./clients"; +import { useDeviceSetStateCounts } from "./useDeviceSetStateCounts"; + +vi.mock("./clients", () => ({ + deviceSetClient: { + getDeviceSetStats: vi.fn(), + }, +})); + +const { mockHandleAuthErrors } = vi.hoisted(() => ({ + mockHandleAuthErrors: vi.fn(({ onError }: { onError: (err: unknown) => void }) => onError), +})); + +vi.mock("@/protoFleet/store", () => ({ + useAuthErrors: vi.fn(() => ({ + handleAuthErrors: mockHandleAuthErrors, + })), +})); + +const mockGetDeviceSetStats = vi.mocked(deviceSetClient.getDeviceSetStats); + +function createMockResponse( + deviceCount: number, + counts: { hashing?: number; broken?: number; offline?: number; sleeping?: number }, +) { + return { + stats: [ + { + deviceCount, + hashingCount: counts.hashing ?? 0, + brokenCount: counts.broken ?? 0, + offlineCount: counts.offline ?? 0, + sleepingCount: counts.sleeping ?? 0, + slotStatuses: [], + }, + ], + }; +} + +describe("useDeviceSetStateCounts", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns initial state when deviceSetId is undefined", () => { + const { result } = renderHook(() => useDeviceSetStateCounts({ deviceSetId: undefined })); + + expect(result.current.totalMiners).toBe(0); + expect(result.current.stateCounts).toBeUndefined(); + expect(result.current.hasLoaded).toBe(false); + expect(mockGetDeviceSetStats).not.toHaveBeenCalled(); + }); + + it("fetches counts when deviceSetId is provided", async () => { + mockGetDeviceSetStats.mockResolvedValue( + createMockResponse(42, { hashing: 30, broken: 5, offline: 4, sleeping: 3 }) as any, + ); + + const { result } = renderHook(() => useDeviceSetStateCounts({ deviceSetId: 1n })); + + await waitFor(() => { + expect(result.current.hasLoaded).toBe(true); + }); + + expect(result.current.totalMiners).toBe(42); + expect(result.current.stateCounts?.hashingCount).toBe(30); + expect(result.current.stateCounts?.brokenCount).toBe(5); + expect(result.current.stateCounts?.offlineCount).toBe(4); + expect(result.current.stateCounts?.sleepingCount).toBe(3); + }); + + it("resets state when deviceSetId changes", async () => { + mockGetDeviceSetStats.mockResolvedValue(createMockResponse(10, { hashing: 10 }) as any); + + const { result, rerender } = renderHook(({ deviceSetId }) => useDeviceSetStateCounts({ deviceSetId }), { + initialProps: { deviceSetId: 1n as bigint | undefined }, + }); + + await waitFor(() => { + expect(result.current.hasLoaded).toBe(true); + }); + expect(result.current.totalMiners).toBe(10); + + // Change deviceSetId — state should reset + mockGetDeviceSetStats.mockResolvedValue(createMockResponse(20, { hashing: 15, offline: 5 }) as any); + rerender({ deviceSetId: 2n }); + + // Before new fetch resolves, state should be cleared + expect(result.current.stats).toBeUndefined(); + expect(result.current.hasLoaded).toBe(false); + + await waitFor(() => { + expect(result.current.hasLoaded).toBe(true); + }); + expect(result.current.totalMiners).toBe(20); + expect(result.current.stateCounts?.offlineCount).toBe(5); + }); + + it("sets hasLoaded on error so consumers are not stuck loading", async () => { + mockGetDeviceSetStats.mockRejectedValue(new Error("network error")); + + const { result } = renderHook(() => useDeviceSetStateCounts({ deviceSetId: 1n })); + + await waitFor(() => { + expect(result.current.hasLoaded).toBe(true); + }); + + // hasLoaded is true even on error — page can render with empty stats + expect(result.current.totalMiners).toBe(0); + expect(result.current.stateCounts).toBeUndefined(); + expect(result.current.isLoading).toBe(false); + }); + + it("does not fetch when deviceSetId transitions to undefined", async () => { + mockGetDeviceSetStats.mockResolvedValue(createMockResponse(10, { hashing: 10 }) as any); + + const { result, rerender } = renderHook(({ deviceSetId }) => useDeviceSetStateCounts({ deviceSetId }), { + initialProps: { deviceSetId: 1n as bigint | undefined }, + }); + + await waitFor(() => { + expect(result.current.hasLoaded).toBe(true); + }); + + rerender({ deviceSetId: undefined }); + + // Should not have made a second call + expect(mockGetDeviceSetStats).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/protoFleet/api/useDeviceSetStateCounts.ts b/client/src/protoFleet/api/useDeviceSetStateCounts.ts new file mode 100644 index 000000000..9ac946afd --- /dev/null +++ b/client/src/protoFleet/api/useDeviceSetStateCounts.ts @@ -0,0 +1,130 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { deviceSetClient } from "@/protoFleet/api/clients"; +import { type DeviceSetStats } from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; +import { useAuthErrors } from "@/protoFleet/store"; + +interface UseDeviceSetStateCountsOptions { + deviceSetId: bigint | undefined; + pollIntervalMs?: number; +} + +interface StateCounts { + hashingCount: number; + brokenCount: number; + offlineCount: number; + sleepingCount: number; +} + +interface UseDeviceSetStateCountsReturn { + totalMiners: number; + stateCounts: StateCounts | undefined; + stats: DeviceSetStats | undefined; + isLoading: boolean; + hasLoaded: boolean; + refetch: () => void; +} + +export const useDeviceSetStateCounts = ({ + deviceSetId, + pollIntervalMs, +}: UseDeviceSetStateCountsOptions): UseDeviceSetStateCountsReturn => { + const { handleAuthErrors } = useAuthErrors(); + + const [stats, setStats] = useState(undefined); + const [isLoading, setIsLoading] = useState(false); + const [hasLoaded, setHasLoaded] = useState(false); + + const requestIdRef = useRef(0); + const hasLoadedRef = useRef(false); + + // Reset on deviceSetId change — invalidate in-flight requests so stale responses can't land + const prevIdRef = useRef(deviceSetId); + if (prevIdRef.current !== deviceSetId) { + prevIdRef.current = deviceSetId; + ++requestIdRef.current; + hasLoadedRef.current = false; + setHasLoaded(false); + setStats(undefined); + } + + const fetchStats = useCallback(async () => { + if (deviceSetId === undefined) { + ++requestIdRef.current; + setStats(undefined); + setIsLoading(false); + return; + } + + const thisRequestId = ++requestIdRef.current; + + if (!hasLoadedRef.current) { + setIsLoading(true); + } + + try { + const response = await deviceSetClient.getDeviceSetStats({ + deviceSetIds: [deviceSetId], + }); + + if (thisRequestId !== requestIdRef.current) return; + + const deviceSetStats = response.stats[0]; + setStats(deviceSetStats); + } catch (error) { + if (thisRequestId !== requestIdRef.current) return; + + handleAuthErrors({ + error, + onError: (err) => { + console.error("Error fetching device set stats:", err); + }, + }); + } finally { + if (thisRequestId === requestIdRef.current) { + setIsLoading(false); + hasLoadedRef.current = true; + setHasLoaded(true); + } + } + }, [deviceSetId, handleAuthErrors]); + + // Initial fetch + refetch on deviceSetId change + useEffect(() => { + fetchStats(); + }, [fetchStats]); + + // Polling + useEffect(() => { + if (!pollIntervalMs || deviceSetId === undefined) return; + + const intervalId = setInterval(() => { + void fetchStats(); + }, pollIntervalMs); + + return () => clearInterval(intervalId); + }, [pollIntervalMs, deviceSetId, fetchStats]); + + const stateCounts: StateCounts | undefined = useMemo( + () => + stats + ? { + hashingCount: stats.hashingCount, + brokenCount: stats.brokenCount, + offlineCount: stats.offlineCount, + sleepingCount: stats.sleepingCount, + } + : undefined, + [stats], + ); + + const totalMiners = stats?.deviceCount ?? 0; + + return { + totalMiners, + stateCounts, + stats, + isLoading, + hasLoaded, + refetch: fetchStats, + }; +}; diff --git a/client/src/protoFleet/api/useDeviceSets.test.ts b/client/src/protoFleet/api/useDeviceSets.test.ts new file mode 100644 index 000000000..c994153aa --- /dev/null +++ b/client/src/protoFleet/api/useDeviceSets.test.ts @@ -0,0 +1,166 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Code, ConnectError } from "@connectrpc/connect"; + +const mockListDeviceSetMembers = vi.fn(); + +vi.mock("./clients", () => ({ + deviceSetClient: { + listDeviceSetMembers: (...args: unknown[]) => mockListDeviceSetMembers(...args), + }, +})); + +const mockHandleAuthErrors = vi.fn(); + +vi.mock("@/protoFleet/store", () => ({ + useAuthErrors: vi.fn(() => ({ + handleAuthErrors: mockHandleAuthErrors, + })), +})); + +// Import after mocks are set up +const { useDeviceSets } = await import("./useDeviceSets"); + +describe("useDeviceSets — listGroupMembers", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockHandleAuthErrors.mockImplementation(({ onError }: { onError: () => void }) => onError()); + }); + + it("returns member IDs via onSuccess on normal completion", async () => { + mockListDeviceSetMembers.mockResolvedValueOnce({ + members: [{ deviceIdentifier: "d1" }, { deviceIdentifier: "d2" }], + nextPageToken: "", + }); + + const onSuccess = vi.fn(); + const onFinally = vi.fn(); + + const { result } = renderHook(() => useDeviceSets()); + + await act(async () => { + await result.current.listGroupMembers({ + deviceSetId: 1n, + onSuccess, + onFinally, + }); + }); + + expect(onSuccess).toHaveBeenCalledWith(["d1", "d2"]); + expect(onFinally).toHaveBeenCalledTimes(1); + }); + + it("does not call onError or handleAuthErrors when AbortError is thrown", async () => { + mockListDeviceSetMembers.mockRejectedValueOnce(new DOMException("aborted", "AbortError")); + + const onSuccess = vi.fn(); + const onError = vi.fn(); + const onFinally = vi.fn(); + + const { result } = renderHook(() => useDeviceSets()); + + await act(async () => { + await result.current.listGroupMembers({ + deviceSetId: 1n, + onSuccess, + onError, + onFinally, + }); + }); + + expect(onSuccess).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + expect(mockHandleAuthErrors).not.toHaveBeenCalled(); + expect(onFinally).toHaveBeenCalledTimes(1); + }); + + it("does not call onError when ConnectError with Canceled code is thrown after signal abort", async () => { + const controller = new AbortController(); + controller.abort(); + + mockListDeviceSetMembers.mockRejectedValueOnce(new ConnectError("canceled", Code.Canceled)); + + const onError = vi.fn(); + const onFinally = vi.fn(); + + const { result } = renderHook(() => useDeviceSets()); + + await act(async () => { + await result.current.listGroupMembers({ + deviceSetId: 1n, + signal: controller.signal, + onError, + onFinally, + }); + }); + + expect(onError).not.toHaveBeenCalled(); + expect(mockHandleAuthErrors).not.toHaveBeenCalled(); + expect(onFinally).toHaveBeenCalledTimes(1); + }); + + it("calls handleAuthErrors when ConnectError with Canceled code is thrown without an aborted signal", async () => { + mockListDeviceSetMembers.mockRejectedValueOnce(new ConnectError("canceled", Code.Canceled)); + + const onError = vi.fn(); + const onFinally = vi.fn(); + + const { result } = renderHook(() => useDeviceSets()); + + await act(async () => { + await result.current.listGroupMembers({ + deviceSetId: 1n, + onError, + onFinally, + }); + }); + + expect(mockHandleAuthErrors).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledTimes(1); + expect(onFinally).toHaveBeenCalledTimes(1); + }); + + it("still calls handleAuthErrors for Unauthenticated error even if signal is aborted", async () => { + const controller = new AbortController(); + controller.abort(); + + mockListDeviceSetMembers.mockRejectedValueOnce(new ConnectError("session expired", Code.Unauthenticated)); + + const onError = vi.fn(); + const onFinally = vi.fn(); + + const { result } = renderHook(() => useDeviceSets()); + + await act(async () => { + await result.current.listGroupMembers({ + deviceSetId: 1n, + signal: controller.signal, + onError, + onFinally, + }); + }); + + expect(mockHandleAuthErrors).toHaveBeenCalledTimes(1); + expect(onFinally).toHaveBeenCalledTimes(1); + }); + + it("calls onError via handleAuthErrors for non-abort RPC errors", async () => { + mockListDeviceSetMembers.mockRejectedValueOnce(new ConnectError("internal error", Code.Internal)); + + const onError = vi.fn(); + const onFinally = vi.fn(); + + const { result } = renderHook(() => useDeviceSets()); + + await act(async () => { + await result.current.listGroupMembers({ + deviceSetId: 1n, + onError, + onFinally, + }); + }); + + expect(mockHandleAuthErrors).toHaveBeenCalledTimes(1); + expect(onFinally).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/protoFleet/api/useDeviceSets.ts b/client/src/protoFleet/api/useDeviceSets.ts new file mode 100644 index 000000000..62aa82fda --- /dev/null +++ b/client/src/protoFleet/api/useDeviceSets.ts @@ -0,0 +1,858 @@ +import { useCallback } from "react"; +import { create } from "@bufbuild/protobuf"; +import { Code, ConnectError } from "@connectrpc/connect"; + +import { deviceSetClient } from "@/protoFleet/api/clients"; +import { + DeviceIdentifierListSchema, + DeviceSelectorSchema, +} from "@/protoFleet/api/generated/common/v1/device_selector_pb"; +import { type SortConfig } from "@/protoFleet/api/generated/common/v1/sort_pb"; +import { + type DeviceSet, + type DeviceSetStats, + DeviceSetType, + type RackCoolingType, + RackInfoSchema, + type RackOrderIndex, + type RackSlot, + type RackSlotPosition, + RackSlotPositionSchema, + RackSlotSchema, + type RackType, +} from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { useAuthErrors } from "@/protoFleet/store"; + +interface CreateGroupProps { + label: string; + deviceIdentifiers?: string[]; + allDevices?: boolean; + onSuccess?: (deviceSet: DeviceSet) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface UpdateGroupProps { + deviceSetId: bigint; + label?: string; + deviceIdentifiers?: string[]; + allDevices?: boolean; + onSuccess?: (deviceSet: DeviceSet) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface DeleteGroupProps { + deviceSetId: bigint; + onSuccess?: () => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface ListDeviceSetsProps { + pageSize?: number; + pageToken?: string; + sort?: SortConfig; + errorComponentTypes?: number[]; + zones?: string[]; + onSuccess?: (deviceSets: DeviceSet[], nextPageToken: string, totalCount: number) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface AddDevicesToDeviceSetProps { + deviceSetId: bigint; + deviceIdentifiers?: string[]; + allDevices?: boolean; + onSuccess?: (addedCount: number) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface GetDeviceSetProps { + deviceSetId: bigint; + onSuccess?: (deviceSet: DeviceSet) => void; + onNotFound?: () => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface GetDeviceSetStatsProps { + deviceSetIds: bigint[]; + onSuccess?: (stats: DeviceSetStats[]) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface CreateRackProps { + label: string; + zone: string; + rows: number; + columns: number; + orderIndex: RackOrderIndex; + coolingType: RackCoolingType; + onSuccess?: (deviceSet: DeviceSet) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface ListRackZonesProps { + onSuccess?: (zones: string[]) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface ListRackTypesProps { + onSuccess?: (rackTypes: RackType[]) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface ListGroupMembersProps { + deviceSetId: bigint; + signal?: AbortSignal; + onSuccess?: (deviceIdentifiers: string[]) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface RemoveDevicesFromDeviceSetProps { + deviceSetId: bigint; + deviceIdentifiers?: string[]; + allDevices?: boolean; + onSuccess?: (removedCount: number) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface UpdateRackProps { + deviceSetId: bigint; + label?: string; + zone?: string; + rows?: number; + columns?: number; + orderIndex?: RackOrderIndex; + coolingType?: RackCoolingType; + onSuccess?: (deviceSet: DeviceSet) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface GetRackSlotsProps { + deviceSetId: bigint; + onSuccess?: (slots: RackSlot[]) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface SetRackSlotPositionProps { + deviceSetId: bigint; + deviceIdentifier: string; + position: RackSlotPosition; + onSuccess?: (slot: RackSlot) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface ClearRackSlotPositionProps { + deviceSetId: bigint; + deviceIdentifier: string; + onSuccess?: () => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface SaveRackProps { + deviceSetId?: bigint; + label: string; + zone: string; + rows: number; + columns: number; + orderIndex: RackOrderIndex; + coolingType: RackCoolingType; + deviceIdentifiers: string[]; + allDevices?: boolean; + slotAssignments: { deviceIdentifier: string; row: number; column: number }[]; + onSuccess?: (deviceSet: DeviceSet, assignedCount: number) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +const memberPageSize = 250; + +function buildDeviceSelector(deviceIdentifiers: string[] | undefined, allDevices: boolean | undefined) { + if (allDevices) { + return create(DeviceSelectorSchema, { + selectionType: { + case: "allDevices", + value: true, + }, + }); + } + // When deviceIdentifiers is provided (even empty), build a device list selector + if (deviceIdentifiers !== undefined) { + return create(DeviceSelectorSchema, { + selectionType: { + case: "deviceList", + value: create(DeviceIdentifierListSchema, { + deviceIdentifiers, + }), + }, + }); + } + return undefined; +} + +function getDeviceSetErrorMessage(err: unknown, kind: "group" | "rack"): string { + if (err instanceof ConnectError && err.code === Code.AlreadyExists) { + return `A ${kind} with this name already exists`; + } + return getErrorMessage(err); +} + +const useDeviceSets = () => { + const { handleAuthErrors } = useAuthErrors(); + + const createGroup = useCallback( + async ({ label, deviceIdentifiers = [], allDevices = false, onSuccess, onError, onFinally }: CreateGroupProps) => { + try { + const deviceSelector = + allDevices || deviceIdentifiers.length > 0 ? buildDeviceSelector(deviceIdentifiers, allDevices) : undefined; + + const createResponse = await deviceSetClient.createDeviceSet({ + type: DeviceSetType.GROUP, + label, + deviceSelector, + }); + + const deviceSet = createResponse.deviceSet; + if (!deviceSet) { + onError?.("Failed to create group"); + return; + } + + onSuccess?.(deviceSet); + } catch (err) { + handleAuthErrors({ + error: err, + onError: (error) => { + onError?.(getDeviceSetErrorMessage(error, "group")); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const updateGroup = useCallback( + async ({ deviceSetId, label, deviceIdentifiers, allDevices, onSuccess, onError, onFinally }: UpdateGroupProps) => { + try { + const deviceSelector = buildDeviceSelector(deviceIdentifiers, allDevices); + + const response = await deviceSetClient.updateDeviceSet({ + deviceSetId, + label, + deviceSelector, + }); + + const deviceSet = response.deviceSet; + if (!deviceSet) { + onError?.("Failed to update group"); + return; + } + + onSuccess?.(deviceSet); + } catch (err) { + handleAuthErrors({ + error: err, + onError: (error) => { + onError?.(getDeviceSetErrorMessage(error, "group")); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const deleteGroup = useCallback( + async ({ deviceSetId, onSuccess, onError, onFinally }: DeleteGroupProps) => { + try { + await deviceSetClient.deleteDeviceSet({ deviceSetId }); + onSuccess?.(); + } catch (err) { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const listGroups = useCallback( + async ({ pageSize, pageToken, sort, errorComponentTypes, onSuccess, onError, onFinally }: ListDeviceSetsProps) => { + try { + if (pageSize) { + const response = await deviceSetClient.listDeviceSets({ + type: DeviceSetType.GROUP, + pageSize, + pageToken: pageToken ?? "", + sort, + errorComponentTypes: errorComponentTypes ?? [], + }); + onSuccess?.(response.deviceSets, response.nextPageToken, response.totalCount); + } else { + // Server caps pageSize at 1000, so we page through all results + // to support callers that need the full unpaginated list. + const all: DeviceSet[] = []; + let nextToken = ""; + do { + const response = await deviceSetClient.listDeviceSets({ + type: DeviceSetType.GROUP, + pageSize: 1000, + pageToken: nextToken, + sort, + }); + all.push(...response.deviceSets); + nextToken = response.nextPageToken; + } while (nextToken); + onSuccess?.(all, "", all.length); + } + } catch (err) { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const listRacks = useCallback( + async ({ + pageSize, + pageToken, + sort, + errorComponentTypes, + zones, + onSuccess, + onError, + onFinally, + }: ListDeviceSetsProps) => { + try { + if (pageSize) { + const response = await deviceSetClient.listDeviceSets({ + type: DeviceSetType.RACK, + pageSize, + pageToken: pageToken ?? "", + sort, + errorComponentTypes: errorComponentTypes ?? [], + zones: zones ?? [], + }); + onSuccess?.(response.deviceSets, response.nextPageToken, response.totalCount); + } else { + // Server caps pageSize at 1000, so we page through all results + // to support callers that need the full unpaginated list. + const all: DeviceSet[] = []; + let nextToken = ""; + do { + const response = await deviceSetClient.listDeviceSets({ + type: DeviceSetType.RACK, + pageSize: 1000, + pageToken: nextToken, + sort, + zones: zones ?? [], + }); + all.push(...response.deviceSets); + nextToken = response.nextPageToken; + } while (nextToken); + onSuccess?.(all, "", all.length); + } + } catch (err) { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const getDeviceSet = useCallback( + async ({ deviceSetId, onSuccess, onNotFound, onError, onFinally }: GetDeviceSetProps) => { + try { + const response = await deviceSetClient.getDeviceSet({ deviceSetId }); + const deviceSet = response.deviceSet; + if (!deviceSet) { + onNotFound?.(); + return; + } + onSuccess?.(deviceSet); + } catch (err) { + if (err instanceof ConnectError && err.code === Code.NotFound) { + onNotFound?.(); + } else { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + } + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const listGroupMembers = useCallback( + async ({ deviceSetId, signal, onSuccess, onError, onFinally }: ListGroupMembersProps) => { + try { + const allIdentifiers: string[] = []; + let pageToken = ""; + + do { + const response = await deviceSetClient.listDeviceSetMembers( + { + deviceSetId, + pageSize: memberPageSize, + pageToken, + }, + { signal }, + ); + for (const member of response.members) { + allIdentifiers.push(member.deviceIdentifier); + } + pageToken = response.nextPageToken; + } while (pageToken !== ""); + + onSuccess?.(allIdentifiers); + } catch (err) { + if ( + (err instanceof DOMException && err.name === "AbortError") || + (err instanceof ConnectError && err.code === Code.Canceled && signal?.aborted) + ) { + return; + } + + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const getDeviceSetStats = useCallback( + async ({ deviceSetIds, onSuccess, onError, onFinally }: GetDeviceSetStatsProps) => { + try { + const response = await deviceSetClient.getDeviceSetStats({ deviceSetIds }); + onSuccess?.(response.stats); + } catch (err) { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const addDevicesToDeviceSet = useCallback( + async ({ + deviceSetId, + deviceIdentifiers, + allDevices, + onSuccess, + onError, + onFinally, + }: AddDevicesToDeviceSetProps) => { + try { + const deviceSelector = + allDevices || (deviceIdentifiers && deviceIdentifiers.length > 0) + ? buildDeviceSelector(deviceIdentifiers, allDevices) + : undefined; + + const response = await deviceSetClient.addDevicesToDeviceSet({ + deviceSetId, + deviceSelector, + }); + + onSuccess?.(response.addedCount); + } catch (err) { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const createRack = useCallback( + async ({ label, zone, rows, columns, orderIndex, coolingType, onSuccess, onError, onFinally }: CreateRackProps) => { + try { + const rackInfo = create(RackInfoSchema, { + rows, + columns, + zone, + orderIndex, + coolingType, + }); + + const createResponse = await deviceSetClient.createDeviceSet({ + type: DeviceSetType.RACK, + label, + typeDetails: { + case: "rackInfo", + value: rackInfo, + }, + }); + + const deviceSet = createResponse.deviceSet; + if (!deviceSet) { + onError?.("Failed to create rack"); + return; + } + + onSuccess?.(deviceSet); + } catch (err) { + handleAuthErrors({ + error: err, + onError: (error) => { + onError?.(getDeviceSetErrorMessage(error, "rack")); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const listRackZones = useCallback( + async ({ onSuccess, onError, onFinally }: ListRackZonesProps) => { + try { + const response = await deviceSetClient.listRackZones({}); + onSuccess?.(response.zones); + } catch (err) { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const listRackTypes = useCallback( + async ({ onSuccess, onError, onFinally }: ListRackTypesProps) => { + try { + const response = await deviceSetClient.listRackTypes({}); + onSuccess?.(response.rackTypes); + } catch (err) { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const removeDevicesFromDeviceSet = useCallback( + async ({ + deviceSetId, + deviceIdentifiers, + allDevices, + onSuccess, + onError, + onFinally, + }: RemoveDevicesFromDeviceSetProps) => { + try { + const deviceSelector = + allDevices || (deviceIdentifiers && deviceIdentifiers.length > 0) + ? buildDeviceSelector(deviceIdentifiers, allDevices) + : undefined; + + const response = await deviceSetClient.removeDevicesFromDeviceSet({ + deviceSetId, + deviceSelector, + }); + + onSuccess?.(response.removedCount); + } catch (err) { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const updateRack = useCallback( + async ({ + deviceSetId, + label, + zone, + rows, + columns, + orderIndex, + coolingType, + onSuccess, + onError, + onFinally, + }: UpdateRackProps) => { + try { + const rackInfo = + zone !== undefined || + rows !== undefined || + columns !== undefined || + orderIndex !== undefined || + coolingType !== undefined + ? create(RackInfoSchema, { + ...(zone !== undefined && { zone }), + ...(rows !== undefined && { rows }), + ...(columns !== undefined && { columns }), + ...(orderIndex !== undefined && { orderIndex }), + ...(coolingType !== undefined && { coolingType }), + }) + : undefined; + + const response = await deviceSetClient.updateDeviceSet({ + deviceSetId, + label, + ...(rackInfo && { + typeDetails: { + case: "rackInfo" as const, + value: rackInfo, + }, + }), + }); + + const deviceSet = response.deviceSet; + if (!deviceSet) { + onError?.("Failed to update rack"); + return; + } + + onSuccess?.(deviceSet); + } catch (err) { + handleAuthErrors({ + error: err, + onError: (error) => { + onError?.(getDeviceSetErrorMessage(error, "rack")); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const getRackSlots = useCallback( + async ({ deviceSetId, onSuccess, onError, onFinally }: GetRackSlotsProps) => { + try { + const response = await deviceSetClient.getRackSlots({ deviceSetId }); + onSuccess?.(response.slots); + } catch (err) { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const setRackSlotPosition = useCallback( + async ({ deviceSetId, deviceIdentifier, position, onSuccess, onError, onFinally }: SetRackSlotPositionProps) => { + try { + const response = await deviceSetClient.setRackSlotPosition({ + deviceSetId, + deviceIdentifier, + position, + }); + + const slot = response.slot; + if (!slot) { + onError?.("Failed to set slot position"); + return; + } + + onSuccess?.(slot); + } catch (err) { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const clearRackSlotPosition = useCallback( + async ({ deviceSetId, deviceIdentifier, onSuccess, onError, onFinally }: ClearRackSlotPositionProps) => { + try { + await deviceSetClient.clearRackSlotPosition({ + deviceSetId, + deviceIdentifier, + }); + onSuccess?.(); + } catch (err) { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const saveRack = useCallback( + async ({ + deviceSetId, + label, + zone, + rows, + columns, + orderIndex, + coolingType, + deviceIdentifiers, + allDevices, + slotAssignments, + onSuccess, + onError, + onFinally, + }: SaveRackProps) => { + try { + const rackInfo = create(RackInfoSchema, { + rows, + columns, + zone, + orderIndex, + coolingType, + }); + + const deviceSelector = buildDeviceSelector(deviceIdentifiers, allDevices); + + const rackSlots = slotAssignments.map((sa) => + create(RackSlotSchema, { + deviceIdentifier: sa.deviceIdentifier, + position: create(RackSlotPositionSchema, { + row: sa.row, + column: sa.column, + }), + }), + ); + + const response = await deviceSetClient.saveRack({ + deviceSetId, + label, + rackInfo, + deviceSelector, + slotAssignments: rackSlots, + }); + + const deviceSet = response.deviceSet; + if (!deviceSet) { + onError?.("Failed to save rack"); + return; + } + + onSuccess?.(deviceSet, response.assignedCount); + } catch (err) { + handleAuthErrors({ + error: err, + onError: (error) => { + onError?.(getDeviceSetErrorMessage(error, "rack")); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + return { + createGroup, + createRack, + updateGroup, + updateRack, + deleteGroup, + getDeviceSet, + listGroups, + listRacks, + listRackZones, + listRackTypes, + listGroupMembers, + getDeviceSetStats, + addDevicesToDeviceSet, + removeDevicesFromDeviceSet, + getRackSlots, + setRackSlotPosition, + clearRackSlotPosition, + saveRack, + }; +}; + +export { useDeviceSets }; +export type { ListDeviceSetsProps }; diff --git a/client/src/protoFleet/api/useExportActivity.ts b/client/src/protoFleet/api/useExportActivity.ts new file mode 100644 index 000000000..d91886c9a --- /dev/null +++ b/client/src/protoFleet/api/useExportActivity.ts @@ -0,0 +1,59 @@ +import { useCallback, useRef, useState } from "react"; +import { activityClient } from "@/protoFleet/api/clients"; +import type { ActivityFilter } from "@/protoFleet/api/generated/activity/v1/activity_pb"; +import { useAuthErrors } from "@/protoFleet/store"; +import { pushToast, STATUSES as TOAST_STATUSES } from "@/shared/features/toaster"; +import { downloadBlob, getFileName } from "@/shared/utils/utility"; + +const MIN_EXPORT_LOADING_MS = 400; + +const sleep = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms)); + +export function useExportActivity() { + const [isExporting, setIsExporting] = useState(false); + const isExportingRef = useRef(false); + const { handleAuthErrors } = useAuthErrors(); + + const handleExportCsv = useCallback( + async (filter?: ActivityFilter) => { + if (isExportingRef.current) return; + + const startedAt = Date.now(); + isExportingRef.current = true; + setIsExporting(true); + + try { + const chunks: Uint8Array[] = []; + + for await (const chunk of activityClient.exportActivities({ filter })) { + chunks.push(new Uint8Array(chunk.chunk)); + } + + const blob = new Blob(chunks, { type: "text/csv;charset=utf-8;" }); + downloadBlob(blob, getFileName("activity-export")); + } catch (error) { + handleAuthErrors({ + error, + onError: (err) => { + console.error("Error exporting activities:", err); + pushToast({ + status: TOAST_STATUSES.error, + message: "Failed to export activities. Please try again.", + }); + }, + }); + } finally { + const elapsedMs = Date.now() - startedAt; + const remainingMs = MIN_EXPORT_LOADING_MS - elapsedMs; + if (remainingMs > 0) { + await sleep(remainingMs); + } + isExportingRef.current = false; + setIsExporting(false); + } + }, + [handleAuthErrors], + ); + + return { exportCsv: handleExportCsv, isExportingCsv: isExporting }; +} diff --git a/client/src/protoFleet/api/useExportMinerListCsv.ts b/client/src/protoFleet/api/useExportMinerListCsv.ts new file mode 100644 index 000000000..e7fbbaea2 --- /dev/null +++ b/client/src/protoFleet/api/useExportMinerListCsv.ts @@ -0,0 +1,78 @@ +import { useCallback, useRef, useState } from "react"; +import { create } from "@bufbuild/protobuf"; +import { fleetManagementClient } from "@/protoFleet/api/clients"; +import { + CsvTemperatureUnit, + ExportMinerListCsvRequestSchema, + type MinerListFilter, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { useAuthErrors, useTemperatureUnit } from "@/protoFleet/store"; +import { pushToast, STATUSES as TOAST_STATUSES } from "@/shared/features/toaster"; +import { downloadBlob, getFileName } from "@/shared/utils/utility"; + +type UseExportMinerListCsvOptions = { + filter?: MinerListFilter; +}; + +const MIN_EXPORT_LOADING_MS = 400; + +const sleep = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms)); + +const useExportMinerListCsv = ({ filter }: UseExportMinerListCsvOptions) => { + const [isExporting, setIsExporting] = useState(false); + const isExportingRef = useRef(false); + const temperatureUnit = useTemperatureUnit(); + const { handleAuthErrors } = useAuthErrors(); + + const handleExportCsv = useCallback(async () => { + if (isExportingRef.current) { + return; + } + + const startedAt = Date.now(); + isExportingRef.current = true; + setIsExporting(true); + + try { + const chunks: Uint8Array[] = []; + + for await (const chunk of fleetManagementClient.exportMinerListCsv( + create(ExportMinerListCsvRequestSchema, { + filter, + temperatureUnit: temperatureUnit === "F" ? CsvTemperatureUnit.FAHRENHEIT : CsvTemperatureUnit.CELSIUS, + }), + )) { + chunks.push(new Uint8Array(chunk.csvData)); + } + + const blob = new Blob(chunks, { type: "text/csv;charset=utf-8;" }); + downloadBlob(blob, getFileName("proto-fleet-miner-snapshot")); + } catch (error) { + handleAuthErrors({ + error, + onError: (err) => { + console.error("Error exporting miner list CSV:", err); + pushToast({ + status: TOAST_STATUSES.error, + message: "Failed to export miners. Please try again.", + }); + }, + }); + } finally { + const elapsedMs = Date.now() - startedAt; + const remainingMs = MIN_EXPORT_LOADING_MS - elapsedMs; + if (remainingMs > 0) { + await sleep(remainingMs); + } + isExportingRef.current = false; + setIsExporting(false); + } + }, [filter, handleAuthErrors, temperatureUnit]); + + return { + exportCsv: handleExportCsv, + isExportingCsv: isExporting, + }; +}; + +export default useExportMinerListCsv; diff --git a/client/src/protoFleet/api/useFileUpload.test.ts b/client/src/protoFleet/api/useFileUpload.test.ts new file mode 100644 index 000000000..a6d501120 --- /dev/null +++ b/client/src/protoFleet/api/useFileUpload.test.ts @@ -0,0 +1,254 @@ +import { renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useFileUpload } from "./useFileUpload"; + +const mockLogout = vi.fn(); + +vi.mock("@/protoFleet/store", () => ({ + useLogout: () => mockLogout, +})); + +describe("useFileUpload", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe("direct upload (XHR)", () => { + let xhrInstances: MockXHR[]; + + class MockXHR { + open = vi.fn(); + send = vi.fn(); + abort = vi.fn(); + withCredentials = false; + status = 0; + statusText = ""; + responseText = ""; + upload = { addEventListener: vi.fn() }; + private listeners: Record void)[]> = {}; + + constructor() { + xhrInstances.push(this); + } + + addEventListener(event: string, handler: () => void) { + if (!this.listeners[event]) this.listeners[event] = []; + this.listeners[event].push(handler); + } + + trigger(event: string) { + this.listeners[event]?.forEach((h) => h()); + } + } + + beforeEach(() => { + xhrInstances = []; + vi.stubGlobal("XMLHttpRequest", MockXHR); + }); + + it("sends multipart POST with credentials and resolves parsed JSON", async () => { + const file = new File(["data"], "file.swu"); + const { result } = renderHook(() => useFileUpload()); + const promise = result.current.upload("/upload", file); + + const xhr = xhrInstances[0]; + expect(xhr.open).toHaveBeenCalledWith("POST", "/upload"); + expect(xhr.withCredentials).toBe(true); + expect(xhr.send).toHaveBeenCalledWith(expect.any(FormData)); + + xhr.status = 200; + xhr.responseText = JSON.stringify({ id: "abc" }); + xhr.trigger("load"); + + await expect(promise).resolves.toEqual({ id: "abc" }); + }); + + it("calls logout on 401", async () => { + const file = new File(["data"], "file.swu"); + const { result } = renderHook(() => useFileUpload()); + const promise = result.current.upload("/upload", file); + + const xhr = xhrInstances[0]; + xhr.status = 401; + xhr.trigger("load"); + + await expect(promise).rejects.toThrow("Session expired"); + expect(mockLogout).toHaveBeenCalledOnce(); + }); + + it("surfaces server error message from JSON body", async () => { + const file = new File(["data"], "file.swu"); + const { result } = renderHook(() => useFileUpload()); + const promise = result.current.upload("/upload", file); + + const xhr = xhrInstances[0]; + xhr.status = 400; + xhr.responseText = JSON.stringify({ error: "bad input" }); + xhr.trigger("load"); + + await expect(promise).rejects.toThrow("bad input"); + expect(mockLogout).not.toHaveBeenCalled(); + }); + + it("reports progress via onProgress", async () => { + const file = new File(["data"], "file.swu"); + const onProgress = vi.fn(); + const { result } = renderHook(() => useFileUpload()); + const promise = result.current.upload("/upload", file, { onProgress }); + + const xhr = xhrInstances[0]; + expect(xhr.upload.addEventListener).toHaveBeenCalledWith("progress", expect.any(Function)); + + const handler = xhr.upload.addEventListener.mock.calls[0][1]; + handler({ lengthComputable: true, loaded: 25, total: 100 }); + expect(onProgress).toHaveBeenCalledWith(25); + + xhr.status = 200; + xhr.responseText = "{}"; + xhr.trigger("load"); + await promise; + }); + + it("aborts on signal", async () => { + const controller = new AbortController(); + const file = new File(["data"], "file.swu"); + const { result } = renderHook(() => useFileUpload()); + const promise = result.current.upload("/upload", file, { signal: controller.signal }); + + controller.abort(); + xhrInstances[0].trigger("abort"); + + await expect(promise).rejects.toThrow("Upload was cancelled."); + }); + + it("rejects on network error", async () => { + const file = new File(["data"], "file.swu"); + const { result } = renderHook(() => useFileUpload()); + const promise = result.current.upload("/upload", file); + + xhrInstances[0].trigger("error"); + + await expect(promise).rejects.toThrow("Network error during upload."); + }); + + it("uses custom fieldName", async () => { + const file = new File(["data"], "file.swu"); + const { result } = renderHook(() => useFileUpload()); + result.current.upload("/upload", file, { fieldName: "firmware" }); + + const xhr = xhrInstances[0]; + const formData: FormData = xhr.send.mock.calls[0][0]; + expect(formData.get("firmware")).toBeTruthy(); + }); + }); + + describe("chunked upload (fetch)", () => { + function mockFetchSequence(...responses: Array<{ status: number; body?: object }>) { + const mocked = vi.fn(); + for (const { status, body } of responses) { + mocked.mockResolvedValueOnce({ + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? "OK" : "Error", + json: () => Promise.resolve(body ?? {}), + }); + } + vi.stubGlobal("fetch", mocked); + return mocked; + } + + const chunkedConfig = { + enabled: true, + chunkSize: 5, + initiateUrl: "/upload/chunked", + chunkUrl: (id: string) => `/upload/chunked/${id}`, + completeUrl: (id: string) => `/upload/chunked/${id}/complete`, + }; + + it("uploads via initiate → PUT chunks → complete", async () => { + const mockFetch = mockFetchSequence( + { status: 200, body: { upload_id: "u1" } }, + { status: 200 }, + { status: 200 }, + { status: 200, body: { firmware_file_id: "fw-1" } }, + ); + + const file = new File(["a".repeat(10)], "file.swu"); + const { result } = renderHook(() => useFileUpload()); + const data = await result.current.upload("/ignored", file, { chunked: chunkedConfig }); + + expect(data).toEqual({ firmware_file_id: "fw-1" }); + expect(mockFetch).toHaveBeenCalledTimes(4); + + expect(mockFetch.mock.calls[0][0]).toBe("/upload/chunked"); + expect(mockFetch.mock.calls[1][0]).toBe("/upload/chunked/u1"); + expect(mockFetch.mock.calls[2][0]).toBe("/upload/chunked/u1"); + expect(mockFetch.mock.calls[3][0]).toBe("/upload/chunked/u1/complete"); + }); + + it("calls logout on 401 during initiate", async () => { + mockFetchSequence({ status: 401 }); + const file = new File(["data"], "file.swu"); + const { result } = renderHook(() => useFileUpload()); + + await expect(result.current.upload("/x", file, { chunked: chunkedConfig })).rejects.toThrow("Session expired"); + expect(mockLogout).toHaveBeenCalledOnce(); + }); + + it("calls logout on 401 during chunk upload", async () => { + mockFetchSequence({ status: 200, body: { upload_id: "u1" } }, { status: 401 }); + const file = new File(["a".repeat(10)], "file.swu"); + const { result } = renderHook(() => useFileUpload()); + + await expect(result.current.upload("/x", file, { chunked: chunkedConfig })).rejects.toThrow("Session expired"); + expect(mockLogout).toHaveBeenCalledOnce(); + }); + + it("reports progress after each chunk", async () => { + mockFetchSequence( + { status: 200, body: { upload_id: "u1" } }, + { status: 200 }, + { status: 200 }, + { status: 200 }, + { status: 200, body: { result: "ok" } }, + ); + + const file = new File(["a".repeat(15)], "file.swu"); + const onProgress = vi.fn(); + const { result } = renderHook(() => useFileUpload()); + await result.current.upload("/x", file, { chunked: chunkedConfig, onProgress }); + + expect(onProgress).toHaveBeenCalledTimes(3); + expect(onProgress).toHaveBeenNthCalledWith(1, 33); + expect(onProgress).toHaveBeenNthCalledWith(2, 67); + expect(onProgress).toHaveBeenNthCalledWith(3, 100); + }); + + it("respects abort signal between chunks", async () => { + const controller = new AbortController(); + mockFetchSequence({ status: 200, body: { upload_id: "u1" } }, { status: 200 }); + + const file = new File(["a".repeat(15)], "file.swu"); + const { result } = renderHook(() => useFileUpload()); + + controller.abort(); + await expect( + result.current.upload("/x", file, { chunked: chunkedConfig, signal: controller.signal }), + ).rejects.toThrow(); + }); + + it("throws when initiate is missing upload_id", async () => { + mockFetchSequence({ status: 200, body: {} }); + const file = new File(["data"], "file.swu"); + const { result } = renderHook(() => useFileUpload()); + + await expect(result.current.upload("/x", file, { chunked: chunkedConfig })).rejects.toThrow( + "Server response missing upload_id.", + ); + }); + }); +}); diff --git a/client/src/protoFleet/api/useFileUpload.ts b/client/src/protoFleet/api/useFileUpload.ts new file mode 100644 index 000000000..16ef36c26 --- /dev/null +++ b/client/src/protoFleet/api/useFileUpload.ts @@ -0,0 +1,207 @@ +import { useCallback, useMemo } from "react"; +import { useLogout } from "@/protoFleet/store"; + +export interface ChunkedUploadConfig { + enabled: boolean; + chunkSize: number; + initiateUrl: string; + chunkUrl: (uploadId: string) => string; + completeUrl: (uploadId: string) => string; +} + +export interface FileUploadOptions { + onProgress?: (percent: number) => void; + signal?: AbortSignal; + fieldName?: string; + chunked?: ChunkedUploadConfig; +} + +interface ErrorBody { + error?: string; +} + +export async function extractFetchError(response: Response, fallback: string): Promise { + try { + const data: ErrorBody = await response.json(); + if (data.error) return data.error; + } catch { + /* not JSON */ + } + return fallback; +} + +function extractXhrError(responseText: string, fallback: string): string { + try { + const data: ErrorBody = JSON.parse(responseText); + if (data.error) return data.error; + } catch { + /* not JSON */ + } + return fallback; +} + +function handleAuth401(status: number, logout: () => void): void { + if (status === 401) { + logout(); + throw new Error("Session expired. Please log in again."); + } +} + +async function uploadChunked( + file: File, + options: FileUploadOptions & { chunked: ChunkedUploadConfig }, + logout: () => void, +): Promise { + const { chunked, onProgress, signal } = options; + const totalChunks = Math.ceil(file.size / chunked.chunkSize); + + const initResponse = await fetch(chunked.initiateUrl, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ filename: file.name, file_size: file.size }), + signal, + }); + + handleAuth401(initResponse.status, logout); + if (!initResponse.ok) { + throw new Error( + await extractFetchError( + initResponse, + `Failed to initiate upload: ${initResponse.status} ${initResponse.statusText}`, + ), + ); + } + + const initData: { upload_id?: string } = await initResponse.json(); + if (!initData.upload_id) { + throw new Error("Server response missing upload_id."); + } + const uploadId = initData.upload_id; + + for (let i = 0; i < totalChunks; i++) { + if (signal?.aborted) { + throw new Error("Upload was cancelled."); + } + + const start = i * chunked.chunkSize; + const end = Math.min(start + chunked.chunkSize, file.size); + + const chunkResponse = await fetch(chunked.chunkUrl(uploadId), { + method: "PUT", + credentials: "include", + headers: { + "Content-Type": "application/octet-stream", + "Content-Range": `bytes ${start}-${end - 1}/${file.size}`, + }, + body: file.slice(start, end), + signal, + }); + + handleAuth401(chunkResponse.status, logout); + if (!chunkResponse.ok) { + throw new Error( + await extractFetchError( + chunkResponse, + `Chunk upload failed: ${chunkResponse.status} ${chunkResponse.statusText}`, + ), + ); + } + + onProgress?.(Math.round(((i + 1) / totalChunks) * 100)); + } + + const completeResponse = await fetch(chunked.completeUrl(uploadId), { + method: "POST", + credentials: "include", + signal, + }); + + handleAuth401(completeResponse.status, logout); + if (!completeResponse.ok) { + throw new Error( + await extractFetchError( + completeResponse, + `Failed to complete upload: ${completeResponse.status} ${completeResponse.statusText}`, + ), + ); + } + + return completeResponse.json(); +} + +function uploadDirect( + url: string, + file: File, + options: FileUploadOptions | undefined, + logout: () => void, +): Promise { + if (options?.signal?.aborted) { + return Promise.reject(new Error("Upload was cancelled.")); + } + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", url); + xhr.withCredentials = true; + + if (options?.onProgress) { + const onProgress = options.onProgress; + xhr.upload.addEventListener("progress", (event) => { + if (event.lengthComputable) { + onProgress(Math.round((event.loaded / event.total) * 100)); + } + }); + } + + if (options?.signal) { + options.signal.addEventListener("abort", () => xhr.abort(), { once: true }); + } + + xhr.addEventListener("load", () => { + if (xhr.status === 401) { + logout(); + reject(new Error("Session expired. Please log in again.")); + return; + } + + if (xhr.status >= 200 && xhr.status < 300) { + try { + resolve(JSON.parse(xhr.responseText)); + } catch { + reject(new Error("Invalid response from upload endpoint.")); + } + } else { + const message = extractXhrError(xhr.responseText, `Upload failed: ${xhr.status} ${xhr.statusText}`); + reject(new Error(message)); + } + }); + + xhr.addEventListener("error", () => { + reject(new Error("Network error during upload.")); + }); + + xhr.addEventListener("abort", () => { + reject(new Error("Upload was cancelled.")); + }); + + const formData = new FormData(); + formData.append(options?.fieldName ?? "file", file); + xhr.send(formData); + }); +} + +export const useFileUpload = () => { + const logout = useLogout(); + + const upload = useCallback( + async (url: string, file: File, options?: FileUploadOptions): Promise => { + if (options?.chunked?.enabled) { + return uploadChunked(file, options as FileUploadOptions & { chunked: ChunkedUploadConfig }, logout); + } + return uploadDirect(url, file, options, logout); + }, + [logout], + ); + + return useMemo(() => ({ upload }), [upload]); +}; diff --git a/client/src/protoFleet/api/useFirmwareApi.test.ts b/client/src/protoFleet/api/useFirmwareApi.test.ts new file mode 100644 index 000000000..d3f474a19 --- /dev/null +++ b/client/src/protoFleet/api/useFirmwareApi.test.ts @@ -0,0 +1,461 @@ +import { renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { _resetConfigCache, useFirmwareApi, validateFirmwareFile } from "./useFirmwareApi"; + +const mockLogout = vi.fn(); +const mockUpload = vi.fn(); + +vi.mock("@/protoFleet/store", () => ({ + useLogout: () => mockLogout, +})); + +vi.mock("@/protoFleet/api/useFileUpload", async (importOriginal) => ({ + ...(await importOriginal()), + useFileUpload: () => ({ upload: mockUpload }), +})); + +describe("validateFirmwareFile", () => { + const defaultConfig = { allowedExtensions: [".swu", ".tar.gz", ".zip"] }; + const createFile = (name: string, size = 1024): File => new File(["x".repeat(size)], name); + + it("accepts .swu files", () => { + expect(validateFirmwareFile(createFile("firmware.swu"), defaultConfig)).toBeNull(); + }); + + it("accepts .tar.gz files", () => { + expect(validateFirmwareFile(createFile("firmware.tar.gz"), defaultConfig)).toBeNull(); + }); + + it("accepts .zip files", () => { + expect(validateFirmwareFile(createFile("firmware.zip"), defaultConfig)).toBeNull(); + }); + + it("accepts uppercase extensions", () => { + expect(validateFirmwareFile(createFile("firmware.SWU"), defaultConfig)).toBeNull(); + expect(validateFirmwareFile(createFile("firmware.TAR.GZ"), defaultConfig)).toBeNull(); + expect(validateFirmwareFile(createFile("firmware.ZIP"), defaultConfig)).toBeNull(); + }); + + it("rejects unsupported extensions", () => { + expect(validateFirmwareFile(createFile("firmware.bin"), defaultConfig)).toContain("Unsupported file type"); + }); + + it("rejects files with no extension", () => { + expect(validateFirmwareFile(createFile("firmware"), defaultConfig)).toContain("Unsupported file type"); + }); + + it("rejects empty files", () => { + const emptyFile = new File([], "firmware.swu"); + expect(validateFirmwareFile(emptyFile, defaultConfig)).toBe("File is empty."); + }); + + it("rejects files with no filename", () => { + const file = new File(["data"], ""); + expect(validateFirmwareFile(file, defaultConfig)).toBe("No filename provided."); + }); + + it("uses custom extensions from config", () => { + const file = new File(["data"], "firmware.img"); + expect(validateFirmwareFile(file, { allowedExtensions: [".img"] })).toBeNull(); + }); + + it("rejects files exceeding maxFileSizeBytes", () => { + const file = new File(["x".repeat(200)], "firmware.swu"); + expect(validateFirmwareFile(file, { ...defaultConfig, maxFileSizeBytes: 100 })).toContain("File too large"); + }); + + it("accepts files within maxFileSizeBytes", () => { + const file = new File(["x".repeat(50)], "firmware.swu"); + expect(validateFirmwareFile(file, { ...defaultConfig, maxFileSizeBytes: 100 })).toBeNull(); + }); +}); + +describe("useFirmwareApi", () => { + beforeEach(() => { + vi.clearAllMocks(); + _resetConfigCache(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe("checkFirmwareFile", () => { + it("sends POST with JSON body and credentials", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ exists: false }), + }); + vi.stubGlobal("fetch", mockFetch); + + const { result } = renderHook(() => useFirmwareApi()); + await result.current.checkFirmwareFile("abc123"); + + expect(mockFetch).toHaveBeenCalledWith( + "/api-proxy/api/v1/firmware/check", + expect.objectContaining({ + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sha256: "abc123" }), + }), + ); + }); + + it("returns exists and firmwareFileId on success", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ exists: true, firmware_file_id: "file-123" }), + }), + ); + + const { result } = renderHook(() => useFirmwareApi()); + const data = await result.current.checkFirmwareFile("abc123"); + + expect(data).toEqual({ exists: true, firmwareFileId: "file-123" }); + }); + + it("returns exists false when file not found", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ exists: false }), + }), + ); + + const { result } = renderHook(() => useFirmwareApi()); + const data = await result.current.checkFirmwareFile("abc123"); + + expect(data).toEqual({ exists: false, firmwareFileId: undefined }); + }); + + it("calls logout on 401 response", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + }), + ); + + const { result } = renderHook(() => useFirmwareApi()); + await expect(result.current.checkFirmwareFile("abc123")).rejects.toThrow("Session expired"); + + expect(mockLogout).toHaveBeenCalledOnce(); + }); + + it("throws on non-401 HTTP error without calling logout", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + json: () => Promise.reject(new Error("no body")), + }), + ); + + const { result } = renderHook(() => useFirmwareApi()); + await expect(result.current.checkFirmwareFile("abc123")).rejects.toThrow("Firmware check failed: 500"); + + expect(mockLogout).not.toHaveBeenCalled(); + }); + + it("surfaces server error message from JSON body on failure", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 400, + statusText: "Bad Request", + json: () => Promise.resolve({ error: "sha256 must be a 64-character hex string" }), + }), + ); + + const { result } = renderHook(() => useFirmwareApi()); + await expect(result.current.checkFirmwareFile("bad")).rejects.toThrow("sha256 must be a 64-character hex string"); + }); + }); + + describe("uploadFirmwareFile", () => { + it("fetches config then delegates to useFileUpload", async () => { + const configFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + allowed_extensions: [".swu"], + max_file_size_bytes: 500 * 1024 * 1024, + chunk_size_bytes: 1 * 1024 * 1024, + }), + }); + vi.stubGlobal("fetch", configFetch); + mockUpload.mockResolvedValue({ firmware_file_id: "fw-abc" }); + + const file = new File(["data"], "firmware.swu"); + const { result } = renderHook(() => useFirmwareApi()); + const id = await result.current.uploadFirmwareFile(file); + + expect(id).toBe("fw-abc"); + expect(mockUpload).toHaveBeenCalledWith( + "/api-proxy/api/v1/firmware/upload", + file, + expect.objectContaining({ + onProgress: undefined, + signal: undefined, + }), + ); + }); + + it("uses chunked upload when file exceeds chunk size", async () => { + const configFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + allowed_extensions: [".swu"], + chunk_size_bytes: 5, + }), + }); + vi.stubGlobal("fetch", configFetch); + mockUpload.mockResolvedValue({ firmware_file_id: "fw-chunked" }); + + const file = new File(["a".repeat(10)], "firmware.swu"); + const onProgress = vi.fn(); + const { result } = renderHook(() => useFirmwareApi()); + const id = await result.current.uploadFirmwareFile(file, { onProgress }); + + expect(id).toBe("fw-chunked"); + expect(mockUpload).toHaveBeenCalledWith( + "/api-proxy/api/v1/firmware/upload", + file, + expect.objectContaining({ + onProgress, + chunked: expect.objectContaining({ + enabled: true, + chunkSize: 5, + }), + }), + ); + }); + + it("throws when upload response is missing firmware_file_id", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + allowed_extensions: [".swu"], + max_file_size_bytes: 500 * 1024 * 1024, + chunk_size_bytes: 1 * 1024 * 1024, + }), + }), + ); + mockUpload.mockResolvedValue({}); + + const file = new File(["data"], "firmware.swu"); + const { result } = renderHook(() => useFirmwareApi()); + + await expect(result.current.uploadFirmwareFile(file)).rejects.toThrow( + "Server response missing firmware_file_id.", + ); + }); + + it("passes signal through to useFileUpload", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + allowed_extensions: [".swu"], + max_file_size_bytes: 500 * 1024 * 1024, + chunk_size_bytes: 1 * 1024 * 1024, + }), + }), + ); + mockUpload.mockResolvedValue({ firmware_file_id: "fw-1" }); + + const controller = new AbortController(); + const file = new File(["data"], "firmware.swu"); + const { result } = renderHook(() => useFirmwareApi()); + + await result.current.uploadFirmwareFile(file, { signal: controller.signal }); + + expect(mockUpload).toHaveBeenCalledWith( + expect.any(String), + file, + expect.objectContaining({ signal: controller.signal }), + ); + }); + }); + + describe("listFirmwareFiles", () => { + it("sends GET with credentials and returns file list", async () => { + const mockFiles = [{ id: "f1", filename: "fw.swu", size: 1024, uploaded_at: "2025-01-01T00:00:00Z" }]; + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ files: mockFiles }), + }); + vi.stubGlobal("fetch", mockFetch); + + const { result } = renderHook(() => useFirmwareApi()); + const files = await result.current.listFirmwareFiles(); + + expect(mockFetch).toHaveBeenCalledWith( + "/api-proxy/api/v1/firmware/files", + expect.objectContaining({ + method: "GET", + credentials: "include", + }), + ); + expect(files).toEqual(mockFiles); + }); + + it("returns empty array when no files exist", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ files: [] }), + }), + ); + + const { result } = renderHook(() => useFirmwareApi()); + const files = await result.current.listFirmwareFiles(); + + expect(files).toEqual([]); + }); + + it("calls logout on 401 response", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + }), + ); + + const { result } = renderHook(() => useFirmwareApi()); + await expect(result.current.listFirmwareFiles()).rejects.toThrow("Session expired"); + expect(mockLogout).toHaveBeenCalledOnce(); + }); + + it("throws on server error", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + json: () => Promise.reject(new Error("no body")), + }), + ); + + const { result } = renderHook(() => useFirmwareApi()); + await expect(result.current.listFirmwareFiles()).rejects.toThrow("Failed to list firmware files"); + }); + }); + + describe("deleteFirmwareFile", () => { + it("sends DELETE with file ID and credentials", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 204, + }); + vi.stubGlobal("fetch", mockFetch); + + const { result } = renderHook(() => useFirmwareApi()); + await result.current.deleteFirmwareFile("file-123"); + + expect(mockFetch).toHaveBeenCalledWith( + "/api-proxy/api/v1/firmware/files/file-123", + expect.objectContaining({ + method: "DELETE", + credentials: "include", + }), + ); + }); + + it("calls logout on 401 response", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + }), + ); + + const { result } = renderHook(() => useFirmwareApi()); + await expect(result.current.deleteFirmwareFile("file-123")).rejects.toThrow("Session expired"); + expect(mockLogout).toHaveBeenCalledOnce(); + }); + + it("throws on 404 response", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: "Not Found", + json: () => Promise.resolve({ error: "firmware file not found" }), + }), + ); + + const { result } = renderHook(() => useFirmwareApi()); + await expect(result.current.deleteFirmwareFile("missing-id")).rejects.toThrow("firmware file not found"); + }); + }); + + describe("deleteAllFirmwareFiles", () => { + it("sends DELETE and returns deleted count", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ deleted_count: 3 }), + }); + vi.stubGlobal("fetch", mockFetch); + + const { result } = renderHook(() => useFirmwareApi()); + const data = await result.current.deleteAllFirmwareFiles(); + + expect(mockFetch).toHaveBeenCalledWith( + "/api-proxy/api/v1/firmware/files", + expect.objectContaining({ + method: "DELETE", + credentials: "include", + }), + ); + expect(data).toEqual({ deleted_count: 3 }); + }); + + it("calls logout on 401 response", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + }), + ); + + const { result } = renderHook(() => useFirmwareApi()); + await expect(result.current.deleteAllFirmwareFiles()).rejects.toThrow("Session expired"); + expect(mockLogout).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/client/src/protoFleet/api/useFirmwareApi.ts b/client/src/protoFleet/api/useFirmwareApi.ts new file mode 100644 index 000000000..25a0c2a08 --- /dev/null +++ b/client/src/protoFleet/api/useFirmwareApi.ts @@ -0,0 +1,268 @@ +import { useCallback, useMemo } from "react"; +import { API_PROXY_BASE } from "@/protoFleet/api/constants"; +import { extractFetchError, useFileUpload } from "@/protoFleet/api/useFileUpload"; +import { useLogout } from "@/protoFleet/store"; + +export { computeSha256 } from "@/protoFleet/utils/crypto"; + +const API_BASE = `${API_PROXY_BASE}/api/v1/firmware`; + +const DEFAULT_MAX_FILE_SIZE = 500 * 1024 * 1024; +const DEFAULT_CHUNK_SIZE = 32 * 1024 * 1024; + +export interface FirmwareConfig { + allowedExtensions: string[]; + maxFileSizeBytes: number; + chunkSizeBytes: number; +} + +let configCache: FirmwareConfig | null = null; +let configPromise: Promise | null = null; + +/** @internal Exported for test cleanup only. */ +export function _resetConfigCache(): void { + configCache = null; + configPromise = null; +} + +async function fetchFirmwareConfig(logout: () => void): Promise { + if (configCache) return configCache; + if (configPromise) return configPromise; + + configPromise = (async () => { + try { + const response = await fetch(`${API_BASE}/config`, { + method: "GET", + credentials: "include", + }); + + if (response.status === 401) { + logout(); + throw new Error("Session expired. Please log in again."); + } + + if (!response.ok) { + throw new Error(`Failed to load firmware config: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + if (!Array.isArray(data.allowed_extensions) || data.allowed_extensions.length === 0) { + throw new Error("Server returned invalid firmware config: missing allowed_extensions."); + } + + const config: FirmwareConfig = { + allowedExtensions: data.allowed_extensions, + maxFileSizeBytes: data.max_file_size_bytes ?? DEFAULT_MAX_FILE_SIZE, + chunkSizeBytes: data.chunk_size_bytes ?? DEFAULT_CHUNK_SIZE, + }; + configCache = config; + return config; + } finally { + configPromise = null; + } + })(); + + return configPromise; +} + +export interface FirmwareUploadOptions { + onProgress?: (percent: number) => void; + signal?: AbortSignal; +} + +export function validateFirmwareFile( + file: File, + config: { allowedExtensions: string[]; maxFileSizeBytes?: number }, +): string | null { + if (!file.name) { + return "No filename provided."; + } + const lower = file.name.toLowerCase(); + const valid = config.allowedExtensions.some((ext) => lower.endsWith(ext)); + if (!valid) { + return `Unsupported file type. Allowed: ${config.allowedExtensions.join(", ")}`; + } + if (file.size === 0) { + return "File is empty."; + } + if (config.maxFileSizeBytes && file.size > config.maxFileSizeBytes) { + return `File too large. Maximum size: ${Math.round(config.maxFileSizeBytes / (1024 * 1024))} MB.`; + } + return null; +} + +export interface FirmwareFileInfo { + id: string; + filename: string; + size: number; + uploaded_at: string; +} + +interface CheckFirmwareResponse { + exists: boolean; + firmware_file_id?: string; +} + +export const useFirmwareApi = () => { + const logout = useLogout(); + const { upload } = useFileUpload(); + + const getConfig = useCallback(async (): Promise => { + return fetchFirmwareConfig(logout); + }, [logout]); + + const checkFirmwareFile = useCallback( + async (sha256: string, signal?: AbortSignal): Promise<{ exists: boolean; firmwareFileId?: string }> => { + const response = await fetch(`${API_BASE}/check`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sha256 }), + signal, + }); + + if (response.status === 401) { + logout(); + throw new Error("Session expired. Please log in again."); + } + + if (!response.ok) { + const message = await extractFetchError( + response, + `Firmware check failed: ${response.status} ${response.statusText}`, + ); + throw new Error(message); + } + + const data: CheckFirmwareResponse = await response.json(); + return { + exists: data.exists, + firmwareFileId: data.firmware_file_id, + }; + }, + [logout], + ); + + const uploadFirmwareFile = useCallback( + async (file: File, options?: FirmwareUploadOptions): Promise => { + const config = await fetchFirmwareConfig(logout); + + let data: unknown; + const useChunked = file.size > config.chunkSizeBytes; + if (useChunked) { + data = await upload(`${API_BASE}/upload`, file, { + onProgress: options?.onProgress, + signal: options?.signal, + chunked: { + enabled: true, + chunkSize: config.chunkSizeBytes, + initiateUrl: `${API_BASE}/upload/chunked`, + chunkUrl: (id) => `${API_BASE}/upload/chunked/${encodeURIComponent(id)}`, + completeUrl: (id) => `${API_BASE}/upload/chunked/${encodeURIComponent(id)}/complete`, + }, + }); + } else { + data = await upload(`${API_BASE}/upload`, file, { + onProgress: options?.onProgress, + signal: options?.signal, + }); + } + + const result = data as { firmware_file_id?: string }; + if (!result.firmware_file_id) { + throw new Error("Server response missing firmware_file_id."); + } + return result.firmware_file_id; + }, + [logout, upload], + ); + + const listFirmwareFiles = useCallback( + async (signal?: AbortSignal): Promise => { + const response = await fetch(`${API_BASE}/files`, { + method: "GET", + credentials: "include", + signal, + }); + + if (response.status === 401) { + logout(); + throw new Error("Session expired. Please log in again."); + } + + if (!response.ok) { + const message = await extractFetchError( + response, + `Failed to list firmware files: ${response.status} ${response.statusText}`, + ); + throw new Error(message); + } + + const data = await response.json(); + return (data.files ?? []) as FirmwareFileInfo[]; + }, + [logout], + ); + + const deleteFirmwareFile = useCallback( + async (fileId: string, signal?: AbortSignal): Promise => { + const response = await fetch(`${API_BASE}/files/${encodeURIComponent(fileId)}`, { + method: "DELETE", + credentials: "include", + signal, + }); + + if (response.status === 401) { + logout(); + throw new Error("Session expired. Please log in again."); + } + + if (!response.ok) { + const message = await extractFetchError( + response, + `Failed to delete firmware file: ${response.status} ${response.statusText}`, + ); + throw new Error(message); + } + }, + [logout], + ); + + const deleteAllFirmwareFiles = useCallback( + async (signal?: AbortSignal): Promise<{ deleted_count: number }> => { + const response = await fetch(`${API_BASE}/files`, { + method: "DELETE", + credentials: "include", + signal, + }); + + if (response.status === 401) { + logout(); + throw new Error("Session expired. Please log in again."); + } + + if (!response.ok) { + const message = await extractFetchError( + response, + `Failed to delete all firmware files: ${response.status} ${response.statusText}`, + ); + throw new Error(message); + } + + return (await response.json()) as { deleted_count: number }; + }, + [logout], + ); + + return useMemo( + () => ({ + getConfig, + checkFirmwareFile, + uploadFirmwareFile, + listFirmwareFiles, + deleteFirmwareFile, + deleteAllFirmwareFiles, + }), + [getConfig, checkFirmwareFile, uploadFirmwareFile, listFirmwareFiles, deleteFirmwareFile, deleteAllFirmwareFiles], + ); +}; diff --git a/client/src/protoFleet/api/useFleet.test.ts b/client/src/protoFleet/api/useFleet.test.ts new file mode 100644 index 000000000..3f1e6f247 --- /dev/null +++ b/client/src/protoFleet/api/useFleet.test.ts @@ -0,0 +1,138 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import { fleetManagementClient } from "./clients"; +import useFleet from "./useFleet"; +import { + ListMinerStateSnapshotsResponseSchema, + MinerListFilterSchema, + MinerStateSnapshotSchema, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +vi.mock("./clients", () => ({ + fleetManagementClient: { + listMinerStateSnapshots: vi.fn(), + }, +})); + +const mockHandleAuthErrors = vi.fn(({ onError }) => onError?.(new Error("auth error"))); + +vi.mock("@/protoFleet/store", () => ({ + useAuthErrors: vi.fn(() => ({ + handleAuthErrors: mockHandleAuthErrors, + })), +})); + +vi.mock("@/shared/features/toaster", () => ({ + pushToast: vi.fn(), + STATUSES: { + error: "error", + }, +})); + +const makeMiner = (deviceIdentifier: string, workerName = "") => + create(MinerStateSnapshotSchema, { + deviceIdentifier, + workerName, + }); + +const makeListResponse = (miners: ReturnType[]) => + create(ListMinerStateSnapshotsResponseSchema, { + miners, + cursor: "", + totalMiners: miners.length, + models: [], + }); + +describe("useFleet", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("queues a refetch requested while a fetch is already in flight", async () => { + let resolveFirst: (value: ReturnType) => void; + + const firstPromise = new Promise>((resolve) => { + resolveFirst = resolve; + }); + + vi.mocked(fleetManagementClient.listMinerStateSnapshots) + .mockReturnValueOnce(firstPromise as Promise) + .mockResolvedValueOnce(makeListResponse([makeMiner("miner-2", "worker-new")])); + + const { result } = renderHook(() => useFleet({ pageSize: 10 })); + + await act(async () => { + result.current.refetch(); + }); + + expect(fleetManagementClient.listMinerStateSnapshots).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveFirst!(makeListResponse([makeMiner("miner-1", "worker-old")])); + }); + + await waitFor(() => { + expect(fleetManagementClient.listMinerStateSnapshots).toHaveBeenCalledTimes(2); + expect(result.current.minerIds).toEqual(["miner-2"]); + expect(result.current.miners["miner-2"]?.workerName).toBe("worker-new"); + }); + }); + + it("ignores stale responses when a newer request starts", async () => { + let resolveFirst: (value: ReturnType) => void; + + const firstPromise = new Promise>((resolve) => { + resolveFirst = resolve; + }); + + vi.mocked(fleetManagementClient.listMinerStateSnapshots) + .mockReturnValueOnce(firstPromise as Promise) + .mockResolvedValueOnce(makeListResponse([makeMiner("fresh-miner", "fresh-worker")])); + + const initialFilter = create(MinerListFilterSchema, { models: ["initial-model"] }); + const updatedFilter = create(MinerListFilterSchema, { models: ["updated-model"] }); + + const { result, rerender } = renderHook(({ filter }) => useFleet({ pageSize: 10, filter }), { + initialProps: { filter: initialFilter }, + }); + + rerender({ filter: updatedFilter }); + + await waitFor(() => { + expect(fleetManagementClient.listMinerStateSnapshots).toHaveBeenCalledTimes(2); + }); + + await waitFor(() => { + expect(result.current.minerIds).toEqual(["fresh-miner"]); + expect(result.current.miners["fresh-miner"]?.workerName).toBe("fresh-worker"); + }); + + await act(async () => { + resolveFirst!(makeListResponse([makeMiner("stale-miner", "stale-worker")])); + }); + + await waitFor(() => { + expect(result.current.minerIds).toEqual(["fresh-miner"]); + expect(result.current.miners["fresh-miner"]?.workerName).toBe("fresh-worker"); + }); + }); + + it("updates a visible miner worker name locally before refetch reconciliation", async () => { + vi.mocked(fleetManagementClient.listMinerStateSnapshots).mockResolvedValue( + makeListResponse([makeMiner("miner-1", "worker-old")]), + ); + + const { result } = renderHook(() => useFleet({ pageSize: 10 })); + + await waitFor(() => { + expect(result.current.miners["miner-1"]?.workerName).toBe("worker-old"); + }); + + act(() => { + result.current.updateMinerWorkerName("miner-1", "worker-new"); + }); + + expect(result.current.miners["miner-1"]?.workerName).toBe("worker-new"); + }); +}); diff --git a/client/src/protoFleet/api/useFleet.ts b/client/src/protoFleet/api/useFleet.ts new file mode 100644 index 000000000..d89b1840b --- /dev/null +++ b/client/src/protoFleet/api/useFleet.ts @@ -0,0 +1,429 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { create, equals } from "@bufbuild/protobuf"; +import { fleetManagementClient } from "@/protoFleet/api/clients"; +import { SortConfig, SortConfigSchema } from "@/protoFleet/api/generated/common/v1/sort_pb"; +import { + MinerListFilter, + MinerListFilterSchema, + MinerStateSnapshot, + MinerStateSnapshotSchema, + PairingStatus, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { useAuthErrors } from "@/protoFleet/store"; +import { pushToast, STATUSES as TOAST_STATUSES } from "@/shared/features/toaster"; + +type UseFleetOptions = { + /** + * Enables data fetching and streaming. + * When false, this hook stays idle. + * @default true + */ + enabled?: boolean; + filter?: MinerListFilter; + /** + * Sort configuration for ordering miners. + * When undefined, uses default server-side ordering (discovery order). + */ + sort?: SortConfig; + pageSize?: number; + pairingStatuses?: PairingStatus[]; +}; + +// Constants to prevent re-renders from unstable default values +const DEFAULT_PAIRING_STATUSES: PairingStatus[] = []; +type PendingFetchMode = "refetch" | "refresh"; + +/** + * Hook for managing fleet data with automatic loading, filtering, and pagination. + * + * @param options - Configuration options for the hook + * @param options.filter - Optional filter to apply + * @param options.pageSize - Number of miners to fetch per page (default: 20) + * + * @example + * ```tsx + * const { minerIds, miners, totalMiners, hasMore, isLoading, loadMore, refetch } = useFleet({ + * filter: { status: [ComponentStatus.OK] } + * }); + * + * // With custom page size + * const { minerIds, miners, totalMiners, hasMore, isLoading, loadMore, refetch } = useFleet({ + * pageSize: 50 + * }); + * + * // Load the next page (replaces current data) + * if (hasMore) { + * loadMore(); + * } + * + * // Refetch current filter from scratch + * refetch(); + * ``` + */ +const useFleet = (options: UseFleetOptions = {}) => { + const { + enabled = true, + filter, + sort, + pageSize = 20, + pairingStatuses = DEFAULT_PAIRING_STATUSES, // Use stable reference to prevent re-renders + } = options; + const { handleAuthErrors } = useAuthErrors(); + + // All state is local to this hook instance + const [minerIds, setMinerIds] = useState([]); + const [miners, setMiners] = useState>({}); + const [totalMiners, setTotalMiners] = useState(0); + const [availableModels, setAvailableModels] = useState([]); + + // Pagination state + const [currentPage, setCurrentPage] = useState(0); + // cursorHistory[i] = cursor to pass when fetching page i + // cursorHistory[0] = undefined (first page needs no cursor) + const [cursorHistory, setCursorHistory] = useState<(string | undefined)[]>([undefined]); + + // Internal state for the hook + const [hasMore, setHasMore] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [hasInitialLoadCompleted, setHasInitialLoadCompleted] = useState(false); + const [cursor, setCursor] = useState(); + const pendingFetchModeRef = useRef(null); + const latestRequestIdRef = useRef(0); + + // Fetch initial list using one-time query + const fetchMinerList = useCallback( + async ( + filter: MinerListFilter | undefined, + sort: SortConfig | undefined, + pageCursor?: string, + fetchedPage?: number, + isRefresh: boolean = false, + ) => { + if (!enabled) { + return; + } + + const requestId = ++latestRequestIdRef.current; + setIsLoading(true); + + // Reset initial load flag when fetching page 0 (but not for polling refreshes) + if (!pageCursor && !isRefresh) { + setHasInitialLoadCompleted(false); + } + + try { + // Merge pairing statuses into the filter + const filterWithPairingStatuses = filter ? { ...filter, pairingStatuses } : { pairingStatuses }; + + const response = await fleetManagementClient.listMinerStateSnapshots({ + pageSize, + cursor: pageCursor, + filter: filterWithPairingStatuses, + sort: sort ? [sort] : undefined, + }); + + const { miners, cursor: newCursor, totalMiners: responseTotalMiners, models } = response; + + if (requestId !== latestRequestIdRef.current) { + return; + } + + // Always replace (never append) for page-based pagination + const ids = miners.map((miner) => miner.deviceIdentifier); + const minersMap: Record = {}; + miners.forEach((miner) => { + minersMap[miner.deviceIdentifier] = miner; + }); + + // Only update state if data actually changed — avoids unnecessary + // re-renders of MinerList/deviceItems on every poll when data is unchanged. + setMinerIds((prev) => { + if (prev.length !== ids.length) return ids; + for (let i = 0; i < ids.length; i++) { + if (prev[i] !== ids[i]) return ids; + } + return prev; + }); + setMiners((prev) => { + const prevKeys = Object.keys(prev); + if (prevKeys.length !== ids.length) return minersMap; + for (const miner of miners) { + const prevMiner = prev[miner.deviceIdentifier]; + if (!prevMiner || !equals(MinerStateSnapshotSchema, prevMiner, miner)) return minersMap; + } + return prev; + }); + setTotalMiners(responseTotalMiners); + + // Update available models for filter dropdown + if (models && models.length > 0) { + setAvailableModels(models); + } + + // Store the response cursor for the next page + if (fetchedPage !== undefined) { + setCursorHistory((prev) => { + const next = [...prev]; + next[fetchedPage + 1] = newCursor || undefined; + return next; + }); + } + + // Update internal state (both scopes) + setCursor(newCursor || undefined); + setHasMore(!!newCursor); + } catch (error) { + if (requestId !== latestRequestIdRef.current) { + return; + } + handleAuthErrors({ + error: error, + onError: (err) => { + console.error("Error fetching miner list:", err); + + // Show toast for page 0 fetch errors (not subsequent pages) + if (!pageCursor) { + pushToast({ + status: TOAST_STATUSES.error, + message: "Failed to load miners. Please try again.", + }); + } + }, + }); + } finally { + if (requestId === latestRequestIdRef.current) { + setIsLoading(false); + + // Mark initial load as completed when fetching page 0 (but not for refreshes) + // This ensures UI doesn't get stuck in permanent loading state on error + if (!pageCursor && !isRefresh) { + setHasInitialLoadCompleted(true); + } + + const pendingFetchMode = pendingFetchModeRef.current; + if (pendingFetchMode !== null) { + pendingFetchModeRef.current = null; + + if (pendingFetchMode === "refetch") { + setCurrentPage(0); + setCursorHistory([undefined]); + void fetchMinerListRef.current(filterRef.current, sortRef.current, undefined, 0); + } else { + const currentCursor = cursorHistoryRef.current[currentPageRef.current]; + void fetchMinerListRef.current( + filterRef.current, + sortRef.current, + currentCursor, + currentPageRef.current, + true, + ); + } + } + } + } + }, + [enabled, pairingStatuses, pageSize, handleAuthErrors], + ); + + // Store fetchMinerList in a ref to avoid dependency issues + const fetchMinerListRef = useRef(fetchMinerList); + useEffect(() => { + fetchMinerListRef.current = fetchMinerList; + }, [fetchMinerList]); + + // Store filter in a ref for stable callbacks (refetch, loadMore) + // This prevents callback recreation when filter object reference changes + const filterRef = useRef(filter); + useEffect(() => { + filterRef.current = filter; + }, [filter]); + + // Store sort in a ref for stable callbacks + const sortRef = useRef(sort); + useEffect(() => { + sortRef.current = sort; + }, [sort]); + + // Store cursor in a ref for stable loadMore callback + const cursorRef = useRef(cursor); + useEffect(() => { + cursorRef.current = cursor; + }, [cursor]); + + // Store isLoading in a ref for stable callbacks + const isLoadingRef = useRef(isLoading); + useEffect(() => { + isLoadingRef.current = isLoading; + }, [isLoading]); + + // Store hasMore in a ref for stable loadMore callback + const hasMoreRef = useRef(hasMore); + useEffect(() => { + hasMoreRef.current = hasMore; + }, [hasMore]); + + // Store currentPage in a ref for stable pagination callbacks + const currentPageRef = useRef(currentPage); + useEffect(() => { + currentPageRef.current = currentPage; + }, [currentPage]); + + // Store cursorHistory in a ref for stable pagination callbacks + const cursorHistoryRef = useRef(cursorHistory); + useEffect(() => { + cursorHistoryRef.current = cursorHistory; + }, [cursorHistory]); + + // Stable loadMore callback - uses refs to avoid recreating on state changes + const loadMore = useCallback(() => { + if (!enabled) { + return; + } + + if (hasMoreRef.current && !isLoadingRef.current) { + // Fetch next page - use refs to get current values + fetchMinerListRef.current(filterRef.current, sortRef.current, cursorRef.current); + } + }, [enabled]); + + const goToPage = useCallback( + (targetPage: number) => { + if (!enabled || isLoadingRef.current) return; + const cursor = cursorHistoryRef.current[targetPage]; + setCurrentPage(targetPage); + fetchMinerListRef.current(filterRef.current, sortRef.current, cursor, targetPage); + }, + [enabled], + ); + + const goToNextPage = useCallback(() => { + if (!hasMoreRef.current) return; + goToPage(currentPageRef.current + 1); + }, [goToPage]); + + const goToPrevPage = useCallback(() => { + if (currentPageRef.current === 0) return; + goToPage(currentPageRef.current - 1); + }, [goToPage]); + + // Stable refetch callback - uses refs to avoid recreating on state changes + // This resets to page 0 - use for filter/sort changes + const refetch = useCallback(() => { + if (!enabled) { + return; + } + + if (isLoadingRef.current) { + pendingFetchModeRef.current = "refetch"; + return; + } + + // Reset pagination and start fresh + setCurrentPage(0); + setCursorHistory([undefined]); + fetchMinerListRef.current(filterRef.current, sortRef.current, undefined, 0); + }, [enabled]); + + // Refresh current page without resetting pagination - use for polling + const refreshCurrentPage = useCallback(() => { + if (isLoadingRef.current) { + if (pendingFetchModeRef.current !== "refetch") { + pendingFetchModeRef.current = "refresh"; + } + return; + } + + const currentCursor = cursorHistoryRef.current[currentPageRef.current]; + fetchMinerListRef.current(filterRef.current, sortRef.current, currentCursor, currentPageRef.current, true); + }, []); + + const updateMinerWorkerName = useCallback((deviceIdentifier: string, workerName: string) => { + setMiners((prev) => { + const existingMiner = prev[deviceIdentifier]; + if (!existingMiner || existingMiner.workerName === workerName) { + return prev; + } + + return { + ...prev, + [deviceIdentifier]: create(MinerStateSnapshotSchema, { + ...existingMiner, + workerName, + }), + }; + }); + }, []); + + // Track if this is the initial load and previous filter/sort + const hasLoadedRef = useRef(false); + const wasEnabledRef = useRef(enabled); + const previousFilterRef = useRef(undefined); + const previousSortRef = useRef(undefined); + + // Fetch data when filter or sort changes + useEffect(() => { + if (!enabled) { + wasEnabledRef.current = false; + return; + } + + const wasDisabled = !wasEnabledRef.current; + + // Check if filter actually changed using protobuf deep equality + const filtersEqual = + previousFilterRef.current === filter || // Both undefined or same reference + (previousFilterRef.current !== undefined && + filter !== undefined && + equals(MinerListFilterSchema, previousFilterRef.current, filter)); + + // Check if sort actually changed using protobuf deep equality + const sortsEqual = + previousSortRef.current === sort || // Both undefined or same reference + (previousSortRef.current !== undefined && + sort !== undefined && + equals(SortConfigSchema, previousSortRef.current, sort)); + + const filterChanged = !filtersEqual; + const sortChanged = !sortsEqual; + + if (hasLoadedRef.current && !filterChanged && !sortChanged && !wasDisabled) { + return; // Skip if not first load and neither filter nor sort has changed + } + + // Update refs + previousFilterRef.current = filter; + previousSortRef.current = sort; + hasLoadedRef.current = true; + wasEnabledRef.current = true; + + // Reset cursor and pagination for new filter or sort + if (filterChanged || sortChanged) { + setCursor(undefined); + setCurrentPage(0); + setCursorHistory([undefined]); + } + + // Fetch with filter and sort + void fetchMinerListRef.current(filter, sort, undefined, 0); + }, [enabled, filter, sort]); + + return { + minerIds, + miners, + totalMiners, + hasMore, + isLoading, + hasInitialLoadCompleted, + loadMore, + currentPage, + hasPreviousPage: currentPage > 0, + goToNextPage, + goToPrevPage, + refetch, + refreshCurrentPage, + updateMinerWorkerName, + availableModels, + }; +}; + +export default useFleet; diff --git a/client/src/protoFleet/api/useFleetCounts.ts b/client/src/protoFleet/api/useFleetCounts.ts new file mode 100644 index 000000000..d9eeafda4 --- /dev/null +++ b/client/src/protoFleet/api/useFleetCounts.ts @@ -0,0 +1,120 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { create } from "@bufbuild/protobuf"; +import { fleetManagementClient } from "@/protoFleet/api/clients"; +import { GetMinerStateCountsRequestSchema } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { MinerStateCounts } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { useAuthErrors } from "@/protoFleet/store"; + +interface UseFleetCountsOptions { + pollIntervalMs?: number; +} + +type UseFleetCountsReturn = { + /** Total number of miners */ + totalMiners: number; + /** Counts of miners in different states */ + stateCounts: MinerStateCounts | undefined; + /** Whether the hook is currently loading data */ + isLoading: boolean; + /** Whether at least one successful fetch has completed */ + hasLoaded: boolean; + /** Refetch the counts */ + refetch: () => void; +}; + +/** + * Hook for fetching miner state counts without loading full miner data. + * More efficient than useFleet when only counts are needed (e.g., Dashboard). + * Supports optional polling for periodic refresh. + * + * @example + * ```tsx + * const { totalMiners, stateCounts, isLoading } = useFleetCounts({ pollIntervalMs: 60000 }); + * + * // Display counts + *
Total: {totalMiners}
+ *
Hashing: {stateCounts?.hashingCount ?? 0}
+ *
Offline: {stateCounts?.offlineCount ?? 0}
+ * ``` + */ +const useFleetCounts = (options?: UseFleetCountsOptions): UseFleetCountsReturn => { + const { handleAuthErrors } = useAuthErrors(); + + const [totalMiners, setTotalMiners] = useState(0); + const [stateCounts, setStateCounts] = useState(undefined); + const [isLoading, setIsLoading] = useState(false); + const [hasLoaded, setHasLoaded] = useState(false); + + // Monotonic counter to discard stale responses from overlapping requests + const requestIdRef = useRef(0); + // Track whether we've loaded at least once to suppress loading flash on poll refreshes + const hasLoadedRef = useRef(false); + + const fetchCounts = useCallback(async () => { + const thisRequestId = ++requestIdRef.current; + + // Only show loading spinner on first fetch, not subsequent poll refreshes + if (!hasLoadedRef.current) { + setIsLoading(true); + } + + try { + const request = create(GetMinerStateCountsRequestSchema, {}); + const response = await fleetManagementClient.getMinerStateCounts(request); + + // Discard stale response if a newer request was issued + if (thisRequestId !== requestIdRef.current) return; + + setTotalMiners(response.totalMiners); + setStateCounts(response.stateCounts); + } catch (error) { + if (thisRequestId !== requestIdRef.current) return; + + handleAuthErrors({ + error: error, + onError: (err) => { + console.error("Error fetching miner state counts:", err); + }, + }); + } finally { + if (thisRequestId === requestIdRef.current) { + setIsLoading(false); + hasLoadedRef.current = true; + setHasLoaded(true); + } + } + }, [handleAuthErrors]); + + // Fetch on mount only — polling handles subsequent refreshes + const hasFetchedRef = useRef(false); + useEffect(() => { + if (hasFetchedRef.current) return; + hasFetchedRef.current = true; + void fetchCounts(); + }, [fetchCounts]); + + // Polling + useEffect(() => { + if (!options?.pollIntervalMs) return; + + const intervalId = setInterval(() => { + void fetchCounts(); + }, options.pollIntervalMs); + + return () => clearInterval(intervalId); + }, [options?.pollIntervalMs, fetchCounts]); + + const refetch = useCallback(() => { + void fetchCounts(); + }, [fetchCounts]); + + return { + totalMiners, + stateCounts, + isLoading, + hasLoaded, + refetch, + }; +}; + +export default useFleetCounts; diff --git a/client/src/protoFleet/api/useForemanImport.ts b/client/src/protoFleet/api/useForemanImport.ts new file mode 100644 index 000000000..d298d27fd --- /dev/null +++ b/client/src/protoFleet/api/useForemanImport.ts @@ -0,0 +1,91 @@ +import { useCallback, useMemo, useState } from "react"; +import { create } from "@bufbuild/protobuf"; +import { ConnectError } from "@connectrpc/connect"; +import { foremanImportClient } from "@/protoFleet/api/clients"; +import { + CompleteImportRequestSchema, + type CompleteImportResponse, + ForemanCredentialsSchema, + ImportFromForemanRequestSchema, + type ImportFromForemanResponse, +} from "@/protoFleet/api/generated/foremanimport/v1/foremanimport_pb"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { useAuthErrors } from "@/protoFleet/store"; + +const buildCredentials = (apiKey: string, clientId: string) => create(ForemanCredentialsSchema, { apiKey, clientId }); + +const useForemanImport = () => { + const { handleAuthErrors } = useAuthErrors(); + const [importPending, setImportPending] = useState(false); + + const handleRpcError = useCallback( + (error: unknown, onError?: (m: string) => void) => { + if (error instanceof ConnectError) { + handleAuthErrors({ error, onError: () => onError?.(getErrorMessage(error, "An unexpected error occurred")) }); + } else if (error instanceof Error) { + onError?.(error.message); + } else { + onError?.(getErrorMessage(error)); + } + }, + [handleAuthErrors], + ); + + const importFromForeman = useCallback( + async (args: { + apiKey: string; + clientId: string; + onSuccess: (r: ImportFromForemanResponse) => void; + onError?: (m: string) => void; + }) => { + setImportPending(true); + try { + const response = await foremanImportClient.importFromForeman( + create(ImportFromForemanRequestSchema, { credentials: buildCredentials(args.apiKey, args.clientId) }), + ); + args.onSuccess(response); + } catch (error) { + handleRpcError(error, args.onError); + } finally { + setImportPending(false); + } + }, + [handleRpcError], + ); + + const completeImport = useCallback( + async (args: { + apiKey: string; + clientId: string; + importPools: boolean; + importGroups: boolean; + importRacks: boolean; + pairedDeviceIdentifiers: string[]; + onSuccess: (r: CompleteImportResponse) => void; + onError?: (m: string) => void; + }) => { + try { + const response = await foremanImportClient.completeImport( + create(CompleteImportRequestSchema, { + credentials: buildCredentials(args.apiKey, args.clientId), + importPools: args.importPools, + importGroups: args.importGroups, + importRacks: args.importRacks, + pairedDeviceIdentifiers: args.pairedDeviceIdentifiers, + }), + ); + args.onSuccess(response); + } catch (error) { + handleRpcError(error, args.onError); + } + }, + [handleRpcError], + ); + + return useMemo( + () => ({ importPending, importFromForeman, completeImport }), + [importPending, importFromForeman, completeImport], + ); +}; + +export { useForemanImport }; diff --git a/client/src/protoFleet/api/useLogin.ts b/client/src/protoFleet/api/useLogin.ts new file mode 100644 index 000000000..a646fe161 --- /dev/null +++ b/client/src/protoFleet/api/useLogin.ts @@ -0,0 +1,80 @@ +import { useCallback } from "react"; + +import { authClient } from "@/protoFleet/api/clients"; +import type { AuthenticateRequest } from "@/protoFleet/api/generated/auth/v1/auth_pb"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { + useSetAuthLoading, + useSetIsAuthenticated, + useSetRole, + useSetSessionExpiry, + useSetUsername, +} from "@/protoFleet/store"; +import { useAuthErrors } from "@/protoFleet/store/hooks/useAuth"; + +interface LoginProps { + onError?: (message: string) => void; + onFinally?: () => void; + onSuccess?: (requiresPasswordChange: boolean) => void; + loginRequest: AuthenticateRequest; + /** + * When true, prevents automatic logout on authentication failure. + * Use this for re-authentication flows (e.g., password change verification) + * where a failed attempt should show an error, not log the user out. + */ + skipLogoutOnError?: boolean; +} + +const useLogin = () => { + const setSessionExpiry = useSetSessionExpiry(); + const setIsAuthenticated = useSetIsAuthenticated(); + const setUsername = useSetUsername(); + const setRole = useSetRole(); + const setAuthLoading = useSetAuthLoading(); + const { handleAuthErrors } = useAuthErrors(); + + const login = useCallback( + async ({ loginRequest, onSuccess, onError, onFinally, skipLogoutOnError }: LoginProps) => { + await authClient + .authenticate(loginRequest) + .then((res) => { + const sessionExpiry = res.sessionExpiry; + const userInfo = res.userInfo; + + if (!userInfo) { + throw new Error("User info missing from authentication response"); + } + + // Session cookie is automatically stored by browser + // We just track the expiry and user info in state + setSessionExpiry(new Date(Number(sessionExpiry) * 1000)); + setIsAuthenticated(true); + setUsername(userInfo.username); + setRole(userInfo.role); + setAuthLoading(false); + onSuccess?.(userInfo.requiresPasswordChange); + }) + .catch((err) => { + if (skipLogoutOnError) { + onError?.(getErrorMessage(err)); + return; + } + + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }) + .finally(() => { + onFinally?.(); + }); + }, + [setSessionExpiry, setIsAuthenticated, setUsername, setRole, setAuthLoading, handleAuthErrors], + ); + + return login; +}; + +export { useLogin }; diff --git a/client/src/protoFleet/api/useLogout.ts b/client/src/protoFleet/api/useLogout.ts new file mode 100644 index 000000000..6a9526716 --- /dev/null +++ b/client/src/protoFleet/api/useLogout.ts @@ -0,0 +1,36 @@ +import { useCallback } from "react"; +import { useNavigate } from "react-router-dom"; + +import { authClient } from "@/protoFleet/api/clients"; +import { useFleetStore } from "@/protoFleet/store"; +import { pushToast, STATUSES as TOAST_STATUSES } from "@/shared/features/toaster"; + +/** + * Hook for logging out the user. + * Calls the server to invalidate the session, then clears client-side state. + */ +const useLogoutAction = () => { + const navigate = useNavigate(); + + const logout = useCallback(async () => { + try { + // Call server to invalidate session and clear cookie + await authClient.logout({}); + } catch (err) { + // Show error to user since server-side session may still be valid + console.error("Error during server logout:", err); + pushToast({ + message: "Logout may be incomplete. Your session could not be fully invalidated on the server.", + status: TOAST_STATUSES.error, + }); + } finally { + // Always clear client-side auth state + useFleetStore.getState().auth.logout(); + navigate("/auth"); + } + }, [navigate]); + + return logout; +}; + +export { useLogoutAction }; diff --git a/client/src/protoFleet/api/useMinerCommand.ts b/client/src/protoFleet/api/useMinerCommand.ts new file mode 100644 index 000000000..fc9570e58 --- /dev/null +++ b/client/src/protoFleet/api/useMinerCommand.ts @@ -0,0 +1,500 @@ +import { useCallback, useMemo } from "react"; +import { create } from "@bufbuild/protobuf"; +import { ConnectError } from "@connectrpc/connect"; +import { fleetManagementClient, minerCommandClient } from "@/protoFleet/api/clients"; +import { CoolingMode } from "@/protoFleet/api/generated/common/v1/cooling_pb"; +import { + type DeleteMinersRequest, + type DeleteMinersResponse, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { + BlinkLEDRequest, + BlinkLEDResponse, + CheckCommandCapabilitiesRequestSchema, + CheckCommandCapabilitiesResponse, + CommandType, + DeviceSelector, + DownloadLogsRequest, + DownloadLogsResponse, + FirmwareUpdateRequest, + FirmwareUpdateResponse, + GetCommandBatchLogBundleRequest, + GetCommandBatchLogBundleResponse, + PerformanceMode, + type PoolSlotConfig, + PoolSlotConfigSchema, + RawPoolInfoSchema, + RebootRequest, + RebootResponse, + SetCoolingModeRequestSchema, + SetCoolingModeResponse, + SetPowerTargetRequestSchema, + SetPowerTargetResponse, + StartMiningRequest, + StartMiningResponse, + StopMiningRequest, + StopMiningResponse, + StreamCommandBatchUpdatesRequest, + StreamCommandBatchUpdatesResponse, + UpdateMinerPasswordRequestSchema, + UpdateMinerPasswordResponse, + UpdateMiningPoolsRequestSchema, + UpdateMiningPoolsResponse, +} from "@/protoFleet/api/generated/minercommand/v1/command_pb"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { useAuthErrors } from "@/protoFleet/store"; + +interface BlinkLEDProps { + blinkLEDRequest: BlinkLEDRequest; + onSuccess: (value: BlinkLEDResponse) => void; + onError?: (error: string) => void; +} + +interface StartMiningProps { + startMiningRequest: StartMiningRequest; + onSuccess: (value: StartMiningResponse) => void; + onError?: (error: string) => void; +} + +interface StopMiningProps { + stopMiningRequest: StopMiningRequest; + onSuccess: (value: StopMiningResponse) => void; + onError?: (error: string) => void; +} + +interface DeleteMinersProps { + deleteMinersRequest: DeleteMinersRequest; + onSuccess: (value: DeleteMinersResponse) => void; + onError?: (error: string) => void; +} + +interface RebootProps { + rebootRequest: RebootRequest; + onSuccess: (value: RebootResponse) => void; + onError?: (error: string) => void; +} + +interface StreamCommandBatchUpdatesProps { + streamRequest: StreamCommandBatchUpdatesRequest; + streamAbortController?: AbortController; + onStreamData: (response: StreamCommandBatchUpdatesResponse) => void; + onError?: (error: string) => void; +} + +// Configuration for a single pool slot - either a known pool ID or raw pool info +export type PoolSlotSource = { type: "poolId"; poolId: string } | { type: "rawPool"; url: string; username: string }; + +export interface PoolConfig { + defaultPool: PoolSlotSource; + backup1Pool?: PoolSlotSource; + backup2Pool?: PoolSlotSource; +} + +interface UpdateMiningPoolsProps { + deviceSelector: DeviceSelector; + poolConfig: PoolConfig; + userUsername: string; + userPassword: string; + onSuccess: (value: UpdateMiningPoolsResponse) => void; + onError?: (error: string) => void; +} + +interface SetPowerTargetProps { + deviceSelector: DeviceSelector; + performanceMode: PerformanceMode; + onSuccess: (value: SetPowerTargetResponse) => void; + onError?: (error: string) => void; +} + +interface SetCoolingModeProps { + deviceSelector: DeviceSelector; + coolingMode: CoolingMode; + onSuccess: (value: SetCoolingModeResponse) => void; + onError?: (error: string) => void; +} + +interface CheckCommandCapabilitiesProps { + deviceSelector: DeviceSelector; + commandType: CommandType; + onSuccess: (value: CheckCommandCapabilitiesResponse) => void; + onError?: (error: string) => void; +} + +interface UpdateMinerPasswordProps { + deviceSelector: DeviceSelector; + newPassword: string; + currentPassword: string; + userUsername: string; + userPassword: string; + onSuccess: (value: UpdateMinerPasswordResponse) => void; + onError?: (error: string) => void; +} + +interface DownloadLogsProps { + downloadLogsRequest: DownloadLogsRequest; + onSuccess: (value: DownloadLogsResponse) => void; + onError?: (error: string) => void; +} + +interface FirmwareUpdateProps { + firmwareUpdateRequest: FirmwareUpdateRequest; + onSuccess: (value: FirmwareUpdateResponse) => void; + onError?: (error: string) => void; +} + +interface GetCommandBatchLogBundleProps { + request: GetCommandBatchLogBundleRequest; + onSuccess: (value: GetCommandBatchLogBundleResponse) => void; + onError?: (error: string) => void; +} + +const useMinerCommand = () => { + const { handleAuthErrors } = useAuthErrors(); + + const blinkLED = useCallback( + async ({ blinkLEDRequest, onSuccess, onError }: BlinkLEDProps) => { + await minerCommandClient + .blinkLED(blinkLEDRequest) + .then((response) => onSuccess(response)) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + const startMining = useCallback( + async ({ startMiningRequest, onSuccess, onError }: StartMiningProps) => { + await minerCommandClient + .startMining(startMiningRequest) + .then((response) => onSuccess(response)) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + const stopMining = useCallback( + async ({ stopMiningRequest, onSuccess, onError }: StopMiningProps) => { + await minerCommandClient + .stopMining(stopMiningRequest) + .then((response) => onSuccess(response)) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + const deleteMiners = useCallback( + async ({ deleteMinersRequest, onSuccess, onError }: DeleteMinersProps) => { + await fleetManagementClient + .deleteMiners(deleteMinersRequest) + .then((response) => onSuccess(response)) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + const reboot = useCallback( + async ({ rebootRequest, onSuccess, onError }: RebootProps) => { + await minerCommandClient + .reboot(rebootRequest) + .then((response) => onSuccess(response)) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + const streamCommandBatchUpdates = useCallback( + async ({ streamRequest, streamAbortController, onStreamData, onError }: StreamCommandBatchUpdatesProps) => { + try { + for await (const updateResponse of minerCommandClient.streamCommandBatchUpdates(streamRequest, { + signal: streamAbortController?.signal, + })) { + onStreamData(updateResponse); + } + } catch (error) { + if ( + (error instanceof DOMException && error.name === "AbortError") || + (streamAbortController && streamAbortController.signal.aborted) + ) { + // The stream was aborted, do nothing + return; + } else if (error instanceof ConnectError) { + handleAuthErrors({ + error, + onError: () => { + onError?.(getErrorMessage(error, "An unexpected error occurred")); + }, + }); + } else if (typeof error === "string") { + onError?.(error); + } else { + onError?.(getErrorMessage(error, "An unexpected error occurred")); + } + } + }, + [handleAuthErrors], + ); + + const updateMiningPools = useCallback( + async ({ deviceSelector, poolConfig, userUsername, userPassword, onSuccess, onError }: UpdateMiningPoolsProps) => { + const createPoolSlotConfig = (source: PoolSlotSource): PoolSlotConfig => { + if (source.type === "poolId") { + return create(PoolSlotConfigSchema, { + poolSource: { case: "poolId", value: BigInt(source.poolId) }, + }); + } + return create(PoolSlotConfigSchema, { + poolSource: { + case: "rawPool", + value: create(RawPoolInfoSchema, { + url: source.url, + username: source.username, + }), + }, + }); + }; + + const updateMiningPoolsRequest = create(UpdateMiningPoolsRequestSchema, { + deviceSelector, + defaultPool: createPoolSlotConfig(poolConfig.defaultPool), + backup1Pool: poolConfig.backup1Pool ? createPoolSlotConfig(poolConfig.backup1Pool) : undefined, + backup2Pool: poolConfig.backup2Pool ? createPoolSlotConfig(poolConfig.backup2Pool) : undefined, + userUsername, + userPassword, + }); + + await minerCommandClient + .updateMiningPools(updateMiningPoolsRequest) + .then((response) => onSuccess(response)) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + const setPowerTarget = useCallback( + async ({ deviceSelector, performanceMode, onSuccess, onError }: SetPowerTargetProps) => { + const setPowerTargetRequest = create(SetPowerTargetRequestSchema, { + deviceSelector, + performanceMode, + }); + + await minerCommandClient + .setPowerTarget(setPowerTargetRequest) + .then((response) => onSuccess(response)) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + const setCoolingMode = useCallback( + async ({ deviceSelector, coolingMode, onSuccess, onError }: SetCoolingModeProps) => { + const setCoolingModeRequest = create(SetCoolingModeRequestSchema, { + deviceSelector, + mode: coolingMode, + }); + + await minerCommandClient + .setCoolingMode(setCoolingModeRequest) + .then((response) => onSuccess(response)) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + const checkCommandCapabilities = useCallback( + async ({ deviceSelector, commandType, onSuccess, onError }: CheckCommandCapabilitiesProps) => { + const request = create(CheckCommandCapabilitiesRequestSchema, { + deviceSelector, + commandType, + }); + + await minerCommandClient + .checkCommandCapabilities(request) + .then((response) => onSuccess(response)) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + const updateMinerPassword = useCallback( + async ({ + deviceSelector, + newPassword, + currentPassword, + userUsername, + userPassword, + onSuccess, + onError, + }: UpdateMinerPasswordProps) => { + const request = create(UpdateMinerPasswordRequestSchema, { + deviceSelector, + newPassword, + currentPassword, + userUsername, + userPassword, + }); + + await minerCommandClient + .updateMinerPassword(request) + .then((response) => onSuccess(response)) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + const downloadLogs = useCallback( + async ({ downloadLogsRequest, onSuccess, onError }: DownloadLogsProps) => { + await minerCommandClient + .downloadLogs(downloadLogsRequest) + .then((response) => onSuccess(response)) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + const firmwareUpdate = useCallback( + async ({ firmwareUpdateRequest, onSuccess, onError }: FirmwareUpdateProps) => { + await minerCommandClient + .firmwareUpdate(firmwareUpdateRequest) + .then((response) => onSuccess(response)) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + const getCommandBatchLogBundle = useCallback( + async ({ request, onSuccess, onError }: GetCommandBatchLogBundleProps) => { + await minerCommandClient + .getCommandBatchLogBundle(request) + .then((response) => onSuccess(response)) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + return useMemo( + () => ({ + blinkLED, + startMining, + stopMining, + deleteMiners, + reboot, + streamCommandBatchUpdates, + updateMiningPools, + setPowerTarget, + setCoolingMode, + checkCommandCapabilities, + updateMinerPassword, + downloadLogs, + firmwareUpdate, + getCommandBatchLogBundle, + }), + [ + blinkLED, + startMining, + stopMining, + deleteMiners, + reboot, + streamCommandBatchUpdates, + updateMiningPools, + setPowerTarget, + setCoolingMode, + checkCommandCapabilities, + updateMinerPassword, + downloadLogs, + firmwareUpdate, + getCommandBatchLogBundle, + ], + ); +}; + +export { useMinerCommand }; diff --git a/client/src/protoFleet/api/useMinerCoolingMode.ts b/client/src/protoFleet/api/useMinerCoolingMode.ts new file mode 100644 index 000000000..93df68e49 --- /dev/null +++ b/client/src/protoFleet/api/useMinerCoolingMode.ts @@ -0,0 +1,38 @@ +import { useCallback, useMemo } from "react"; +import { fleetManagementClient } from "@/protoFleet/api/clients"; +import { CoolingMode } from "@/protoFleet/api/generated/common/v1/cooling_pb"; +import { useAuthErrors } from "@/protoFleet/store"; + +const useMinerCoolingMode = () => { + const { handleAuthErrors } = useAuthErrors(); + + const fetchCoolingMode = useCallback( + async (deviceIdentifier: string): Promise => { + try { + const response = await fleetManagementClient.getMinerCoolingMode({ + deviceIdentifier, + }); + + return response.coolingMode; + } catch (err) { + handleAuthErrors({ + error: err, + onError: () => { + console.error("Error fetching miner cooling mode:", err); + }, + }); + return CoolingMode.UNSPECIFIED; + } + }, + [handleAuthErrors], + ); + + return useMemo( + () => ({ + fetchCoolingMode, + }), + [fetchCoolingMode], + ); +}; + +export default useMinerCoolingMode; diff --git a/client/src/protoFleet/api/useMinerModelGroups.ts b/client/src/protoFleet/api/useMinerModelGroups.ts new file mode 100644 index 000000000..a4ea433a7 --- /dev/null +++ b/client/src/protoFleet/api/useMinerModelGroups.ts @@ -0,0 +1,19 @@ +import { useCallback } from "react"; +import { fleetManagementClient } from "@/protoFleet/api/clients"; +import { + type MinerListFilter, + type MinerModelGroup, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +const useMinerModelGroups = () => { + const getMinerModelGroups = useCallback(async (filter: MinerListFilter | null): Promise => { + const response = await fleetManagementClient.getMinerModelGroups({ + filter: filter ?? undefined, + }); + return response.groups; + }, []); + + return { getMinerModelGroups }; +}; + +export default useMinerModelGroups; diff --git a/client/src/protoFleet/api/useMinerPairing.ts b/client/src/protoFleet/api/useMinerPairing.ts new file mode 100644 index 000000000..be568bb8f --- /dev/null +++ b/client/src/protoFleet/api/useMinerPairing.ts @@ -0,0 +1,96 @@ +import { useCallback, useMemo, useState } from "react"; +import { ConnectError } from "@connectrpc/connect"; +import { pairingClient } from "@/protoFleet/api/clients"; +import { Device, DiscoverRequest, PairRequest } from "@/protoFleet/api/generated/pairing/v1/pairing_pb"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { useAuthErrors } from "@/protoFleet/store"; + +interface DiscoverMinersProps { + discoverRequest: DiscoverRequest; + discoverAbortController?: AbortController; + onStreamData: (devices: Device[]) => void; + onError?: (error: string) => void; +} + +interface PairMinersProps { + pairRequest: PairRequest; + onSuccess: (failedDeviceIds: string[]) => void; + onError?: (error: string) => void; +} + +const useMinerPairing = () => { + const { handleAuthErrors } = useAuthErrors(); + + const [discoverPending, setDiscoverPending] = useState(false); + const [pairingPending, setPairingPending] = useState(false); + + const discover = useCallback( + async ({ discoverRequest, discoverAbortController, onStreamData, onError }: DiscoverMinersProps) => { + setDiscoverPending(true); + try { + for await (const discoveryResponse of pairingClient.discover(discoverRequest, { + signal: discoverAbortController?.signal, + })) { + if (discoveryResponse.error) { + onError?.(discoveryResponse.error); + break; + } + + onStreamData(discoveryResponse.devices); + } + } catch (error) { + if ( + (error instanceof DOMException && error.name === "AbortError") || + (discoverAbortController && discoverAbortController.signal.aborted) + ) { + // The discovery was aborted, do nothing + return; + } else if (error instanceof ConnectError) { + handleAuthErrors({ + error: error, + onError: () => { + onError?.(getErrorMessage(error, "An unexpected error occurred")); + }, + }); + } else if (typeof error === "string") { + onError?.(error); + } else { + onError?.(getErrorMessage(error, "An unexpected error occurred")); + } + } finally { + setDiscoverPending(false); + } + }, + [handleAuthErrors], + ); + + const pair = useCallback( + async ({ pairRequest, onSuccess, onError }: PairMinersProps) => { + setPairingPending(true); + await pairingClient + .pair(pairRequest) + .then((response) => { + onSuccess(response.failedDeviceIds || []); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }) + .finally(() => { + setPairingPending(false); + }); + }, + [handleAuthErrors], + ); + + return useMemo( + () => ({ discoverPending, discover, pairingPending, pair }), + [discoverPending, discover, pairingPending, pair], + ); +}; + +export { useMinerPairing }; diff --git a/client/src/protoFleet/api/useMinerPoolAssignments.ts b/client/src/protoFleet/api/useMinerPoolAssignments.ts new file mode 100644 index 000000000..daa3d6e67 --- /dev/null +++ b/client/src/protoFleet/api/useMinerPoolAssignments.ts @@ -0,0 +1,52 @@ +import { useCallback, useMemo, useState } from "react"; +import { fleetManagementClient } from "@/protoFleet/api/clients"; +import { PoolAssignment } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { useAuthErrors } from "@/protoFleet/store"; + +const useMinerPoolAssignments = () => { + const { handleAuthErrors } = useAuthErrors(); + const [pools, setPools] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchPoolAssignments = useCallback( + async (deviceIdentifier: string): Promise => { + setIsLoading(true); + setError(null); + + try { + const response = await fleetManagementClient.getMinerPoolAssignments({ + deviceIdentifier, + }); + + setPools(response.pools); + return response.pools; + } catch (err) { + handleAuthErrors({ + error: err, + onError: () => { + const errorMessage = err instanceof Error ? err.message : String(err); + setError(errorMessage); + console.error("Error fetching miner pool assignments:", err); + }, + }); + return []; + } finally { + setIsLoading(false); + } + }, + [handleAuthErrors], + ); + + return useMemo( + () => ({ + pools, + isLoading, + error, + fetchPoolAssignments, + }), + [pools, isLoading, error, fetchPoolAssignments], + ); +}; + +export default useMinerPoolAssignments; diff --git a/client/src/protoFleet/api/useNetworkInfo.ts b/client/src/protoFleet/api/useNetworkInfo.ts new file mode 100644 index 000000000..95225a176 --- /dev/null +++ b/client/src/protoFleet/api/useNetworkInfo.ts @@ -0,0 +1,75 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { networkInfoClient } from "@/protoFleet/api/clients"; +import { NetworkInfo, UpdateNetworkNicknameRequest } from "@/protoFleet/api/generated/networkinfo/v1/networkinfo_pb"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { useAuthErrors } from "@/protoFleet/store"; + +interface UpdateNetworkInfoProps { + networkUpdateRequest: UpdateNetworkNicknameRequest; + onSuccess: () => void; + onError?: (error: string) => void; +} + +const useNetworkInfo = () => { + const { handleAuthErrors } = useAuthErrors(); + + const [data, setData] = useState(); + const [error, setError] = useState(); + const [pending, setPending] = useState(false); + + const fetchData = useCallback(() => { + setPending(true); + + networkInfoClient + .getNetworkInfo({}) + .then((res) => { + setData(res?.networkInfo); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + setError(getErrorMessage(err)); + }, + }); + }) + .finally(() => { + setPending(false); + }); + }, [handleAuthErrors]); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + fetchData(); + }, [fetchData]); + + const updateNetworkInfo = useCallback( + async ({ networkUpdateRequest, onSuccess, onError }: UpdateNetworkInfoProps) => { + setPending(true); + await networkInfoClient + .updateNetworkNickname(networkUpdateRequest) + .then(() => { + onSuccess(); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }) + .finally(() => { + setPending(false); + }); + }, + [handleAuthErrors], + ); + + return useMemo( + () => ({ fetchData, pending, error, data, updateNetworkInfo }), + [fetchData, pending, error, data, updateNetworkInfo], + ); +}; + +export { useNetworkInfo }; diff --git a/client/src/protoFleet/api/useOnboardedStatus.ts b/client/src/protoFleet/api/useOnboardedStatus.ts new file mode 100644 index 000000000..1808da451 --- /dev/null +++ b/client/src/protoFleet/api/useOnboardedStatus.ts @@ -0,0 +1,63 @@ +import { useCallback, useEffect } from "react"; +import { onboardingClient } from "@/protoFleet/api/clients"; +import type { FleetOnboardingStatus } from "@/protoFleet/api/generated/onboarding/v1/onboarding_pb"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { + useAuthErrors, + useDevicePaired, + useIsAuthenticated, + useOnboardingStatusLoaded, + usePoolConfigured, + useResetOnboardingStatus, + useSetOnboardingStatus, +} from "@/protoFleet/store"; + +const useOnboardedStatus = ({ enabled = true }: { enabled?: boolean } = {}) => { + const isAuthenticated = useIsAuthenticated(); + const poolConfigured = usePoolConfigured(); + const devicePaired = useDevicePaired(); + const statusLoaded = useOnboardingStatusLoaded(); + const setStatus = useSetOnboardingStatus(); + const resetStatus = useResetOnboardingStatus(); + const { handleAuthErrors } = useAuthErrors(); + + const fetchStatus = useCallback(async (): Promise => { + try { + const response = await onboardingClient.getFleetOnboardingStatus({}); + setStatus(response.status ?? null); + return response.status ?? null; + } catch (err: any) { + setStatus(null); + handleAuthErrors({ + error: err, + onError: () => { + const errorMessage = getErrorMessage(err); + throw new Error(`Failed to fetch Onboarded Status: ${errorMessage}`); + }, + }); + return null; + } + }, [setStatus, handleAuthErrors]); + + useEffect(() => { + if (!enabled) { + return; + } + + if (!isAuthenticated) { + resetStatus(); + return; + } + + fetchStatus(); + }, [enabled, fetchStatus, isAuthenticated, resetStatus]); + + return { + poolConfigured, + devicePaired, + statusLoaded, + refetch: fetchStatus, + }; +}; + +export { useOnboardedStatus }; diff --git a/client/src/protoFleet/api/usePoolNeededCount.ts b/client/src/protoFleet/api/usePoolNeededCount.ts new file mode 100644 index 000000000..4af0ef002 --- /dev/null +++ b/client/src/protoFleet/api/usePoolNeededCount.ts @@ -0,0 +1,108 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { create } from "@bufbuild/protobuf"; +import { fleetManagementClient } from "@/protoFleet/api/clients"; +import { + DeviceStatus, + ListMinerStateSnapshotsRequestSchema, + MinerListFilterSchema, + PairingStatus, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { useAuthErrors } from "@/protoFleet/store"; + +type UsePoolNeededCountReturn = { + /** Total number of miners that need pool configuration */ + poolNeededCount: number; + /** Whether the hook is currently loading data */ + isLoading: boolean; + /** Whether the initial load has completed */ + hasInitialLoadCompleted: boolean; + /** Refetch the count */ + refetch: () => void; +}; + +/** + * Hook for fetching the count of miners that need mining pool configuration. + * + * @example + * ```tsx + * const { poolNeededCount, isLoading } = usePoolNeededCount(); + * + * // Display count + * {poolNeededCount > 0 &&
{poolNeededCount} miners need pools
} + * ``` + */ +const usePoolNeededCount = (): UsePoolNeededCountReturn => { + const { handleAuthErrors } = useAuthErrors(); + + const [poolNeededCount, setPoolNeededCount] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [hasInitialLoadCompleted, setHasInitialLoadCompleted] = useState(false); + const isLoadingRef = useRef(false); + const fetchCountRef = useRef<(() => Promise) | null>(null); + + // Fetch only the count (lightweight, single page request) + const fetchCount = useCallback(async () => { + setIsLoading(true); + isLoadingRef.current = true; + + try { + // Create filter for NEEDS_MINING_POOL status with PAIRED pairing status + const filter = create(MinerListFilterSchema, { + deviceStatus: [DeviceStatus.NEEDS_MINING_POOL], + pairingStatuses: [PairingStatus.PAIRED], + }); + + // Fetch only first page to get total count + const request = create(ListMinerStateSnapshotsRequestSchema, { + pageSize: 1, // Minimal page size since we only need the count + cursor: "", + filter, + }); + + const response = await fleetManagementClient.listMinerStateSnapshots(request); + setPoolNeededCount(response.totalMiners); + } catch (error) { + handleAuthErrors({ + error: error, + onError: (err) => { + console.error("[usePoolNeededCount] Error fetching pool needed count:", err); + }, + }); + } finally { + setIsLoading(false); + isLoadingRef.current = false; + setHasInitialLoadCompleted(true); + } + }, [handleAuthErrors]); + + // Store fetchCount in a ref so refetch callback can access latest version without changing identity + fetchCountRef.current = fetchCount; + + // Track if this is the initial load + const hasLoadedRef = useRef(false); + + // Fetch data on mount + useEffect(() => { + if (hasLoadedRef.current) { + return; + } + hasLoadedRef.current = true; + void fetchCount(); + }, [fetchCount]); + + // Use ref-based approach to keep callback stable while accessing latest fetchCount + const refetch = useCallback(() => { + if (!isLoadingRef.current && fetchCountRef.current) { + void fetchCountRef.current(); + } + }, []); + + return { + poolNeededCount, + isLoading, + hasInitialLoadCompleted, + refetch, + }; +}; + +export default usePoolNeededCount; diff --git a/client/src/protoFleet/api/usePools.ts b/client/src/protoFleet/api/usePools.ts new file mode 100644 index 000000000..68bfbc09b --- /dev/null +++ b/client/src/protoFleet/api/usePools.ts @@ -0,0 +1,217 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Duration } from "@bufbuild/protobuf/wkt"; +import { poolsClient } from "@/protoFleet/api/clients"; +import type { + CreatePoolRequest, + DeletePoolRequest, + ListPoolsResponse, + UpdatePoolRequest, + ValidatePoolRequest, +} from "@/protoFleet/api/generated/pools/v1/pools_pb"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { useAuthErrors } from "@/protoFleet/store"; + +interface CreatePoolProps { + createPoolRequest: CreatePoolRequest; + onSuccess?: (poolId: string) => void; + onError?: (error: string) => void; +} + +interface UpdatePoolProps { + updatePoolRequest: UpdatePoolRequest; + onSuccess?: () => void; + onError?: (error: string) => void; +} + +interface DeletePoolProps { + deletePoolRequest: DeletePoolRequest; + onSuccess?: () => void; + onError?: (error: string) => void; +} + +export interface ValidatePoolProps { + poolInfo: Omit; + onSuccess?: () => void; + onError?: (error: string) => void; + onFinally?: () => void; +} + +const usePools = (enabled = true) => { + const { handleAuthErrors } = useAuthErrors(); + + const [pools, setPools] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + const fetchPools = useCallback( + async (showLoading = true) => { + try { + if (showLoading) { + setIsLoading(true); + } + const response = await poolsClient.listPools({}); + + setPools(response.pools); + } catch (error) { + handleAuthErrors({ + error: error, + onError: () => { + console.error("Error fetching pools:", error); + throw error; + }, + }); + } finally { + if (showLoading) { + setIsLoading(false); + } + } + }, + [setPools, handleAuthErrors], + ); + + useEffect(() => { + if (!enabled) { + setIsLoading(false); + return; + } + + fetchPools(); + }, [enabled, fetchPools]); + + const createPool = useCallback( + async ({ createPoolRequest, onSuccess, onError }: CreatePoolProps) => { + await poolsClient + .createPool(createPoolRequest) + .then((response) => { + if (!response.pool || !response.pool.poolId) { + onError?.("Pool created but no pool ID returned"); + return; + } + + const pool = response.pool; + const poolId = pool.poolId; + + setPools((prevPools) => [...prevPools, pool]); + + onSuccess?.(poolId.toString()); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + const updatePool = useCallback( + async ({ updatePoolRequest, onSuccess, onError }: UpdatePoolProps) => { + await poolsClient + .updatePool(updatePoolRequest) + .then(() => { + fetchPools(false); // Don't show loading spinner on refetch + onSuccess?.(); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors, fetchPools], + ); + + const deletePool = useCallback( + async ({ deletePoolRequest, onSuccess, onError }: DeletePoolProps) => { + await poolsClient + .deletePool(deletePoolRequest) + .then(() => { + setPools((prevPools) => prevPools.filter((pool) => pool.poolId !== deletePoolRequest.poolId)); + onSuccess?.(); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + const [validatePoolPending, setValidatePoolPending] = useState(false); + const validatePool = useCallback( + async ({ poolInfo, onSuccess, onError, onFinally }: ValidatePoolProps) => { + setValidatePoolPending(true); + + // Create request object, only include password if it's not empty + const request: Omit = { + url: poolInfo.url, + username: poolInfo.username, + ...(poolInfo.password && poolInfo.password.trim() && { password: poolInfo.password }), + ...(poolInfo.timeout && { + timeout: poolInfo.timeout as Duration, + }), + }; + + await poolsClient + .validatePool(request) + .then(() => { + onSuccess?.(); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }) + .finally(() => { + onFinally?.(); + setValidatePoolPending(false); + }); + }, + [handleAuthErrors], + ); + + // Sort pools by name (case-insensitive) for consistent display + const sortedPools = useMemo( + () => [...pools].sort((a, b) => a.poolName.localeCompare(b.poolName, undefined, { sensitivity: "base" })), + [pools], + ); + + const miningPools = useMemo( + () => + sortedPools.map((pool) => ({ + poolId: pool.poolId.toString(), + name: pool.poolName, + poolUrl: pool.url, + username: pool.username, + })), + [sortedPools], + ); + + return useMemo( + () => ({ + pools: sortedPools, + miningPools, + createPool, + updatePool, + deletePool, + validatePool, + validatePoolPending, + isLoading, + }), + [sortedPools, miningPools, createPool, updatePool, deletePool, validatePool, validatePoolPending, isLoading], + ); +}; + +export default usePools; diff --git a/client/src/protoFleet/api/useRenameMiners.ts b/client/src/protoFleet/api/useRenameMiners.ts new file mode 100644 index 000000000..347cb0f2e --- /dev/null +++ b/client/src/protoFleet/api/useRenameMiners.ts @@ -0,0 +1,74 @@ +import { useCallback, useMemo } from "react"; +import { create } from "@bufbuild/protobuf"; + +import { fleetManagementClient } from "@/protoFleet/api/clients"; +import { DeviceIdentifierListSchema } from "@/protoFleet/api/generated/common/v1/device_selector_pb"; +import { type SortConfig } from "@/protoFleet/api/generated/common/v1/sort_pb"; +import { + type DeviceSelector, + DeviceSelectorSchema, + type MinerNameConfig, + MinerNameConfigSchema, + NamePropertySchema, + RenameMinersRequestSchema, + type RenameMinersResponse, + StringPropertySchema, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { useAuthErrors } from "@/protoFleet/store"; + +const useRenameMiners = () => { + const { handleAuthErrors } = useAuthErrors(); + + const renameMiners = useCallback( + async ( + deviceSelector: DeviceSelector, + nameConfig: MinerNameConfig, + sort?: SortConfig, + ): Promise => { + try { + return await fleetManagementClient.renameMiners( + create(RenameMinersRequestSchema, { + deviceSelector, + nameConfig, + sort: sort ? [sort] : [], + }), + ); + } catch (err) { + handleAuthErrors({ + error: err, + }); + throw err; + } + }, + [handleAuthErrors], + ); + + const renameSingleMiner = useCallback( + async (deviceIdentifier: string, name: string) => { + await renameMiners( + create(DeviceSelectorSchema, { + selectionType: { + case: "includeDevices", + value: create(DeviceIdentifierListSchema, { deviceIdentifiers: [deviceIdentifier] }), + }, + }), + create(MinerNameConfigSchema, { + properties: [ + create(NamePropertySchema, { + kind: { + case: "stringValue", + value: create(StringPropertySchema, { value: name }), + }, + }), + ], + separator: "", + }), + ); + }, + [renameMiners], + ); + + return useMemo(() => ({ renameMiners, renameSingleMiner }), [renameMiners, renameSingleMiner]); +}; + +export default useRenameMiners; diff --git a/client/src/protoFleet/api/useScheduleApi.test.ts b/client/src/protoFleet/api/useScheduleApi.test.ts new file mode 100644 index 000000000..faeb05e13 --- /dev/null +++ b/client/src/protoFleet/api/useScheduleApi.test.ts @@ -0,0 +1,487 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import { TimestampSchema } from "@bufbuild/protobuf/wkt"; + +import { SCHEDULES_CHANGED_EVENT } from "./scheduleEvents"; +import useScheduleApi from "./useScheduleApi"; +import { scheduleClient } from "@/protoFleet/api/clients"; +import { + DayOfWeek, + DeleteScheduleResponseSchema, + ListSchedulesResponseSchema, + PauseScheduleResponseSchema, + ScheduleAction as ProtoScheduleAction, + ScheduleStatus as ProtoScheduleStatus, + ScheduleType as ProtoScheduleType, + RecurrenceFrequency, + ReorderSchedulesResponseSchema, + ResumeScheduleResponseSchema, + ScheduleRecurrenceSchema, + ScheduleSchema, + ScheduleTargetSchema, + ScheduleTargetType, +} from "@/protoFleet/api/generated/schedule/v1/schedule_pb"; + +vi.mock("@/protoFleet/api/clients", () => ({ + scheduleClient: { + listSchedules: vi.fn(), + createSchedule: vi.fn(), + updateSchedule: vi.fn(), + deleteSchedule: vi.fn(), + pauseSchedule: vi.fn(), + resumeSchedule: vi.fn(), + reorderSchedules: vi.fn(), + }, +})); + +vi.mock("@/protoFleet/store", () => ({ + useAuthErrors: () => ({ + handleAuthErrors: ({ onError, error }: { onError?: (error: unknown) => void; error: unknown }) => { + onError?.(error); + }, + }), +})); + +const mockListSchedules = vi.mocked(scheduleClient.listSchedules); +const mockPauseSchedule = vi.mocked(scheduleClient.pauseSchedule); +const mockResumeSchedule = vi.mocked(scheduleClient.resumeSchedule); +const mockDeleteSchedule = vi.mocked(scheduleClient.deleteSchedule); +const mockReorderSchedules = vi.mocked(scheduleClient.reorderSchedules); +const dayFormatter = new Intl.DateTimeFormat(undefined, { weekday: "short" }); +const dateTimeFormatter = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", +}); +const timeFormatter = new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", +}); + +const createTimestamp = (value: string) => { + const date = new Date(value); + + return create(TimestampSchema, { + seconds: BigInt(Math.floor(date.getTime() / 1000)), + nanos: (date.getTime() % 1000) * 1_000_000, + }); +}; + +const createDeferred = () => { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + + return { promise, resolve, reject }; +}; + +const formatExpectedNextRunSummary = (value: string) => { + const nextRun = new Date(value); + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const nextRunDay = new Date(nextRun.getFullYear(), nextRun.getMonth(), nextRun.getDate()); + const dayDifference = Math.round((nextRunDay.getTime() - today.getTime()) / (24 * 60 * 60 * 1000)); + + if (dayDifference === 0) { + return `Runs today at ${timeFormatter.format(nextRun)}`; + } + + if (dayDifference === 1) { + return `Runs tomorrow at ${timeFormatter.format(nextRun)}`; + } + + if (dayDifference > 1 && dayDifference < 7) { + return `Runs ${dayFormatter.format(nextRun)} at ${timeFormatter.format(nextRun)}`; + } + + return `Runs on ${dateTimeFormatter.format(nextRun)}`; +}; + +const createScheduleMessage = ({ + id, + priority, + name, + action, + status, + createdBy, + createdByUsername, + startDate, + startTime, + timezone = "America/Toronto", + nextRunAt, + targets = [], + recurrence, +}: { + id: bigint; + priority: number; + name: string; + action: ProtoScheduleAction; + status: ProtoScheduleStatus; + createdBy: bigint; + createdByUsername?: string; + startDate: string; + startTime: string; + timezone?: string; + nextRunAt?: string; + targets?: Array<{ targetType: ScheduleTargetType; targetId: string }>; + recurrence?: Partial<{ + frequency: RecurrenceFrequency; + interval: number; + daysOfWeek: DayOfWeek[]; + dayOfMonth?: number; + }>; +}) => + create(ScheduleSchema, { + id, + priority, + name, + action, + status, + createdBy, + createdByUsername, + scheduleType: ProtoScheduleType.RECURRING, + recurrence: create(ScheduleRecurrenceSchema, { + frequency: RecurrenceFrequency.WEEKLY, + interval: 1, + daysOfWeek: [DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, DayOfWeek.FRIDAY], + ...recurrence, + }), + startDate, + startTime, + endTime: action === ProtoScheduleAction.SET_POWER_TARGET ? "06:00" : "", + timezone, + nextRunAt: nextRunAt ? createTimestamp(nextRunAt) : undefined, + targets: targets.map((target) => create(ScheduleTargetSchema, target)), + }); + +describe("useScheduleApi", () => { + let dispatchEventSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-30T09:00:00-04:00")); + dispatchEventSpy = vi.spyOn(window, "dispatchEvent"); + }); + + afterEach(() => { + vi.useRealTimers(); + dispatchEventSpy.mockRestore(); + }); + + it("lists schedules from the schedule service and maps them into list rows", async () => { + mockListSchedules.mockResolvedValue( + create(ListSchedulesResponseSchema, { + schedules: [ + createScheduleMessage({ + id: 2n, + priority: 2, + name: "Night sleep", + action: ProtoScheduleAction.SLEEP, + status: ProtoScheduleStatus.ACTIVE, + createdBy: 2n, + createdByUsername: "Rongxin Liu", + startDate: "2026-03-30", + startTime: "22:00", + timezone: "America/Chicago", + nextRunAt: "2026-04-01T02:00:00.000Z", + }), + createScheduleMessage({ + id: 1n, + priority: 1, + name: "Morning reboot", + action: ProtoScheduleAction.REBOOT, + status: ProtoScheduleStatus.PAUSED, + createdBy: 1n, + createdByUsername: "Negar Naghshbandi", + startDate: "2026-03-30", + startTime: "07:00", + nextRunAt: "2026-03-31T11:00:00.000Z", + targets: [{ targetType: ScheduleTargetType.MINER, targetId: "miner-1" }], + }), + ], + }), + ); + + const { result } = renderHook(() => useScheduleApi()); + + await act(async () => { + await result.current.listSchedules(); + }); + + expect(result.current.schedules.map((schedule) => schedule.id)).toEqual(["1", "2"]); + expect(result.current.schedules[0]).toMatchObject({ + name: "Morning reboot", + targetSummary: "Applies to 1 miner", + action: "reboot", + status: "paused", + createdBy: "Negar Naghshbandi", + }); + expect(result.current.schedules[1]).toMatchObject({ + name: "Night sleep", + targetSummary: "Applies to all miners", + scheduleSummary: `Weekdays · ${timeFormatter.format(new Date("2026-04-01T03:00:00.000Z"))}`, + action: "sleep", + status: "active", + createdBy: "Rongxin Liu", + }); + expect(result.current.schedules[1].nextRunSummary).toBe(formatExpectedNextRunSummary("2026-04-01T02:00:00.000Z")); + }); + + it("prefers the server-provided creator username when schedules include it", async () => { + mockListSchedules.mockResolvedValue( + create(ListSchedulesResponseSchema, { + schedules: [ + createScheduleMessage({ + id: 1n, + priority: 1, + name: "Morning reboot", + action: ProtoScheduleAction.REBOOT, + status: ProtoScheduleStatus.ACTIVE, + createdBy: 1n, + createdByUsername: "admin@example.com", + startDate: "2026-03-30", + startTime: "07:00", + }), + ], + }), + ); + + const { result } = renderHook(() => useScheduleApi()); + + await act(async () => { + await result.current.listSchedules(); + }); + + expect(result.current.schedules[0]?.createdBy).toBe("admin@example.com"); + }); + + it("keeps the loading flag idle during background refreshes", async () => { + const deferred = createDeferred>>(); + mockListSchedules.mockReturnValue(deferred.promise); + + const { result } = renderHook(() => useScheduleApi()); + + let refreshPromise: Promise | undefined; + + await act(async () => { + refreshPromise = result.current.refreshSchedules({ background: true }); + }); + + expect(result.current.isLoading).toBe(false); + + deferred.resolve( + create(ListSchedulesResponseSchema, { + schedules: [], + }), + ); + + await act(async () => { + await refreshPromise; + }); + + expect(result.current.isLoading).toBe(false); + }); + + it("reuses the same in-flight refresh across background and foreground callers", async () => { + const deferred = createDeferred>>(); + mockListSchedules.mockReturnValue(deferred.promise); + + const { result } = renderHook(() => useScheduleApi()); + + let backgroundRefreshPromise: Promise | undefined; + let foregroundRefreshPromise: Promise | undefined; + + await act(async () => { + backgroundRefreshPromise = result.current.refreshSchedules({ background: true }); + foregroundRefreshPromise = result.current.refreshSchedules(); + }); + + expect(mockListSchedules).toHaveBeenCalledTimes(1); + expect(result.current.isLoading).toBe(true); + + deferred.resolve( + create(ListSchedulesResponseSchema, { + schedules: [], + }), + ); + + await act(async () => { + await Promise.all([backgroundRefreshPromise, foregroundRefreshPromise]); + }); + + expect(result.current.isLoading).toBe(false); + }); + + it("pauses and resumes schedules via the schedule service", async () => { + mockListSchedules.mockResolvedValue( + create(ListSchedulesResponseSchema, { + schedules: [ + createScheduleMessage({ + id: 1n, + priority: 1, + name: "Morning reboot", + action: ProtoScheduleAction.REBOOT, + status: ProtoScheduleStatus.ACTIVE, + createdBy: 1n, + startDate: "2026-03-30", + startTime: "07:00", + }), + ], + }), + ); + mockPauseSchedule.mockResolvedValue( + create(PauseScheduleResponseSchema, { + schedule: createScheduleMessage({ + id: 1n, + priority: 1, + name: "Morning reboot", + action: ProtoScheduleAction.REBOOT, + status: ProtoScheduleStatus.PAUSED, + createdBy: 1n, + startDate: "2026-03-30", + startTime: "07:00", + }), + }), + ); + mockResumeSchedule.mockResolvedValue( + create(ResumeScheduleResponseSchema, { + schedule: createScheduleMessage({ + id: 1n, + priority: 1, + name: "Morning reboot", + action: ProtoScheduleAction.REBOOT, + status: ProtoScheduleStatus.ACTIVE, + createdBy: 1n, + startDate: "2026-03-30", + startTime: "07:00", + }), + }), + ); + + const { result } = renderHook(() => useScheduleApi()); + + await act(async () => { + await result.current.refreshSchedules(); + await result.current.pauseSchedule("1"); + await result.current.resumeSchedule("1"); + }); + + expect(mockPauseSchedule).toHaveBeenCalledWith(expect.objectContaining({ scheduleId: 1n })); + expect(mockResumeSchedule).toHaveBeenCalledWith(expect.objectContaining({ scheduleId: 1n })); + expect(result.current.schedules[0]?.status).toBe("active"); + expect(dispatchEventSpy.mock.calls.map(([event]: [Event]) => event.type)).toContain(SCHEDULES_CHANGED_EVENT); + }); + + it("reorders schedules through the service and removes deleted schedules locally", async () => { + mockListSchedules.mockResolvedValue( + create(ListSchedulesResponseSchema, { + schedules: [ + createScheduleMessage({ + id: 1n, + priority: 1, + name: "Morning reboot", + action: ProtoScheduleAction.REBOOT, + status: ProtoScheduleStatus.ACTIVE, + createdBy: 1n, + startDate: "2026-03-30", + startTime: "07:00", + }), + createScheduleMessage({ + id: 2n, + priority: 2, + name: "Night curtailment", + action: ProtoScheduleAction.SET_POWER_TARGET, + status: ProtoScheduleStatus.ACTIVE, + createdBy: 2n, + startDate: "2026-03-30", + startTime: "22:00", + }), + ], + }), + ); + mockReorderSchedules.mockResolvedValue(create(ReorderSchedulesResponseSchema, {})); + mockDeleteSchedule.mockResolvedValue(create(DeleteScheduleResponseSchema, {})); + + const { result } = renderHook(() => useScheduleApi()); + + await act(async () => { + await result.current.listSchedules(); + await result.current.reorderSchedules(["2", "1"]); + await result.current.deleteSchedule("1"); + }); + + expect(mockReorderSchedules).toHaveBeenCalledWith(expect.objectContaining({ scheduleIds: [2n, 1n] })); + expect(mockDeleteSchedule).toHaveBeenCalledWith(expect.objectContaining({ scheduleId: 1n })); + expect(result.current.schedules).toEqual([ + expect.objectContaining({ + id: "2", + priority: 1, + }), + ]); + expect(dispatchEventSpy.mock.calls.map(([event]: [Event]) => event.type)).toContain(SCHEDULES_CHANGED_EVENT); + }); + + it("includes weekly and monthly recurrence patterns in schedule summaries", async () => { + mockListSchedules.mockResolvedValue( + create(ListSchedulesResponseSchema, { + schedules: [ + createScheduleMessage({ + id: 1n, + priority: 1, + name: "Midweek reboot", + action: ProtoScheduleAction.REBOOT, + status: ProtoScheduleStatus.ACTIVE, + createdBy: 1n, + startDate: "2026-03-30", + startTime: "07:00", + nextRunAt: "2026-04-01T11:00:00.000Z", + recurrence: { + frequency: RecurrenceFrequency.WEEKLY, + interval: 1, + daysOfWeek: [DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY], + }, + }), + createScheduleMessage({ + id: 2n, + priority: 2, + name: "Monthly reboot", + action: ProtoScheduleAction.REBOOT, + status: ProtoScheduleStatus.ACTIVE, + createdBy: 2n, + startDate: "2026-03-30", + startTime: "02:00", + nextRunAt: "2026-04-01T06:00:00.000Z", + recurrence: { + frequency: RecurrenceFrequency.MONTHLY, + interval: 1, + dayOfMonth: 1, + daysOfWeek: [], + }, + }), + ], + }), + ); + + const { result } = renderHook(() => useScheduleApi()); + + await act(async () => { + await result.current.listSchedules(); + }); + + expect(result.current.schedules[0]).toMatchObject({ + name: "Midweek reboot", + scheduleSummary: `Mon, Wed · ${timeFormatter.format(new Date("2026-04-01T11:00:00.000Z"))}`, + }); + expect(result.current.schedules[1]).toMatchObject({ + name: "Monthly reboot", + scheduleSummary: `1st day of month · ${timeFormatter.format(new Date("2026-04-01T06:00:00.000Z"))}`, + }); + }); +}); diff --git a/client/src/protoFleet/api/useScheduleApi.timezone.test.ts b/client/src/protoFleet/api/useScheduleApi.timezone.test.ts new file mode 100644 index 000000000..ce0f00d56 --- /dev/null +++ b/client/src/protoFleet/api/useScheduleApi.timezone.test.ts @@ -0,0 +1,165 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import process from "node:process"; + +import { + DayOfWeek, + ListSchedulesResponseSchema, + ScheduleAction as ProtoScheduleAction, + ScheduleStatus as ProtoScheduleStatus, + ScheduleType as ProtoScheduleType, + RecurrenceFrequency, + ScheduleRecurrenceSchema, + ScheduleSchema, +} from "@/protoFleet/api/generated/schedule/v1/schedule_pb"; + +const { mockListSchedules } = vi.hoisted(() => ({ + mockListSchedules: vi.fn(), +})); + +vi.mock("@/protoFleet/api/clients", () => ({ + scheduleClient: { + listSchedules: mockListSchedules, + createSchedule: vi.fn(), + updateSchedule: vi.fn(), + deleteSchedule: vi.fn(), + pauseSchedule: vi.fn(), + resumeSchedule: vi.fn(), + reorderSchedules: vi.fn(), + }, +})); + +vi.mock("@/protoFleet/store", () => ({ + useAuthErrors: () => ({ + handleAuthErrors: ({ onError, error }: { onError?: (error: unknown) => void; error: unknown }) => { + onError?.(error); + }, + }), +})); + +const createScheduleMessage = ({ + id, + priority, + name, + createdBy, + startDate, + startTime, + timezone, +}: { + id: bigint; + priority: number; + name: string; + createdBy: bigint; + startDate: string; + startTime: string; + timezone: string; +}) => + create(ScheduleSchema, { + id, + priority, + name, + action: ProtoScheduleAction.REBOOT, + status: ProtoScheduleStatus.PAUSED, + createdBy, + scheduleType: ProtoScheduleType.RECURRING, + recurrence: create(ScheduleRecurrenceSchema, { + frequency: RecurrenceFrequency.WEEKLY, + interval: 1, + daysOfWeek: [DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, DayOfWeek.FRIDAY], + }), + startDate, + startTime, + timezone, + }); + +describe("useScheduleApi DST schedule summaries", () => { + const originalTimeZone = process.env.TZ; + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + process.env.TZ = "UTC"; + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-10T09:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + + if (originalTimeZone === undefined) { + delete process.env.TZ; + return; + } + + process.env.TZ = originalTimeZone; + }); + + it("uses the current schedule date for recurring summaries when nextRunAt is missing", async () => { + const timeFormatter = new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", + }); + + mockListSchedules.mockResolvedValue( + create(ListSchedulesResponseSchema, { + schedules: [ + createScheduleMessage({ + id: 1n, + priority: 1, + name: "Weekday reboot", + createdBy: 1n, + startDate: "2026-01-15", + startTime: "07:00", + timezone: "America/New_York", + }), + ], + }), + ); + + const { default: useScheduleApi } = await import("./useScheduleApi"); + const { result } = renderHook(() => useScheduleApi()); + + await act(async () => { + await result.current.listSchedules(); + }); + + expect(result.current.schedules[0]).toMatchObject({ + name: "Weekday reboot", + scheduleSummary: `Weekdays · ${timeFormatter.format(new Date("2026-07-10T11:00:00.000Z"))}`, + }); + }); + + it("does not shift nonexistent DST-gap wall-clock times to a different local hour", async () => { + mockListSchedules.mockResolvedValue( + create(ListSchedulesResponseSchema, { + schedules: [ + create(ScheduleSchema, { + id: 2n, + priority: 1, + name: "Spring-forward reboot", + action: ProtoScheduleAction.REBOOT, + status: ProtoScheduleStatus.PAUSED, + createdBy: 1n, + scheduleType: ProtoScheduleType.ONE_TIME, + startDate: "2026-03-08", + startTime: "02:30", + timezone: "America/New_York", + }), + ], + }), + ); + + const { default: useScheduleApi } = await import("./useScheduleApi"); + const { result } = renderHook(() => useScheduleApi()); + + await act(async () => { + await result.current.listSchedules(); + }); + + expect(result.current.schedules[0]).toMatchObject({ + name: "Spring-forward reboot", + scheduleSummary: "2026-03-08 at 02:30", + }); + }); +}); diff --git a/client/src/protoFleet/api/useScheduleApi.ts b/client/src/protoFleet/api/useScheduleApi.ts new file mode 100644 index 000000000..355d6c3a5 --- /dev/null +++ b/client/src/protoFleet/api/useScheduleApi.ts @@ -0,0 +1,597 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import { create } from "@bufbuild/protobuf"; + +import { scheduleClient } from "@/protoFleet/api/clients"; +import { + type CreateScheduleRequest, + DayOfWeek, + DeleteScheduleRequestSchema, + ListSchedulesRequestSchema, + PauseScheduleRequestSchema, + ScheduleAction as ProtoScheduleAction, + ScheduleStatus as ProtoScheduleStatus, + ScheduleType as ProtoScheduleType, + RecurrenceFrequency, + type ReorderSchedulesRequest, + ReorderSchedulesRequestSchema, + ResumeScheduleRequestSchema, + type Schedule, + ScheduleTargetType, + type UpdateScheduleRequest, +} from "@/protoFleet/api/generated/schedule/v1/schedule_pb"; +import { emitSchedulesChanged } from "@/protoFleet/api/scheduleEvents"; +import { + addDaysToDateValue, + buildDateInTimeZone, + formatTimeZoneDateParts, + getTimeZoneDateTimeParts, +} from "@/protoFleet/features/settings/utils/scheduleDateUtils"; +import { useAuthErrors } from "@/protoFleet/store"; + +export type ScheduleAction = "setPowerTarget" | "reboot" | "sleep"; +export type ScheduleStatus = "running" | "active" | "paused" | "completed"; + +export interface ScheduleListItem { + id: string; + priority: number; + name: string; + targetSummary: string; + scheduleSummary: string; + nextRunSummary: string | null; + action: ScheduleAction; + status: ScheduleStatus; + createdBy: string; + rawSchedule: Schedule; +} + +interface RefreshSchedulesOptions { + background?: boolean; +} + +const dayFormatter = new Intl.DateTimeFormat(undefined, { weekday: "short" }); +const dateTimeFormatter = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", +}); +const timeFormatter = new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", +}); + +const normalizeSchedules = (schedules: ScheduleListItem[]): ScheduleListItem[] => + [...schedules] + .sort((left, right) => left.priority - right.priority) + .map((schedule, index) => ({ + ...schedule, + priority: index + 1, + })); + +const resequenceSchedules = (schedules: ScheduleListItem[]): ScheduleListItem[] => + schedules.map((schedule, index) => ({ + ...schedule, + priority: index + 1, + })); + +const ensureError = (error: unknown, fallbackMessage: string) => + error instanceof Error ? error : new Error(typeof error === "string" ? error : fallbackMessage); + +const toDate = (seconds: bigint, nanos = 0) => new Date(Number(seconds) * 1000 + Math.floor(nanos / 1_000_000)); + +const formatTimeValue = (value: string, timeZone: string, dateValue: string) => { + const parsed = buildDateInTimeZone(dateValue, value, timeZone); + return parsed ? timeFormatter.format(parsed) : value; +}; + +const formatDateTimeValue = (dateValue: string, timeValue: string, timeZone: string) => { + const parsed = buildDateInTimeZone(dateValue, timeValue, timeZone); + return parsed ? dateTimeFormatter.format(parsed) : `${dateValue} at ${timeValue}`; +}; + +const formatOrdinal = (value: number) => { + const suffix = + value % 10 === 1 && value % 100 !== 11 + ? "st" + : value % 10 === 2 && value % 100 !== 12 + ? "nd" + : value % 10 === 3 && value % 100 !== 13 + ? "rd" + : "th"; + return `${value}${suffix}`; +}; + +const weekdayNames: Record = { + [DayOfWeek.UNSPECIFIED]: "", + [DayOfWeek.SUNDAY]: "Sun", + [DayOfWeek.MONDAY]: "Mon", + [DayOfWeek.TUESDAY]: "Tue", + [DayOfWeek.WEDNESDAY]: "Wed", + [DayOfWeek.THURSDAY]: "Thu", + [DayOfWeek.FRIDAY]: "Fri", + [DayOfWeek.SATURDAY]: "Sat", +}; + +const mapStatus = (status: ProtoScheduleStatus): ScheduleStatus => { + switch (status) { + case ProtoScheduleStatus.RUNNING: + return "running"; + case ProtoScheduleStatus.PAUSED: + return "paused"; + case ProtoScheduleStatus.COMPLETED: + return "completed"; + case ProtoScheduleStatus.ACTIVE: + case ProtoScheduleStatus.UNSPECIFIED: + default: + return "active"; + } +}; + +const mapAction = (schedule: Schedule): ScheduleAction => { + switch (schedule.action) { + case ProtoScheduleAction.REBOOT: + return "reboot"; + case ProtoScheduleAction.SLEEP: + return "sleep"; + case ProtoScheduleAction.SET_POWER_TARGET: + case ProtoScheduleAction.UNSPECIFIED: + default: + return "setPowerTarget"; + } +}; + +const summarizeTargets = (schedule: Schedule) => { + if (schedule.targets.length === 0) { + return "Applies to all miners"; + } + + const rackCount = schedule.targets.filter((target) => target.targetType === ScheduleTargetType.RACK).length; + const groupCount = schedule.targets.filter((target) => target.targetType === ScheduleTargetType.GROUP).length; + const minerCount = schedule.targets.filter((target) => target.targetType === ScheduleTargetType.MINER).length; + const parts = [ + rackCount > 0 ? `${rackCount} ${rackCount === 1 ? "rack" : "racks"}` : null, + groupCount > 0 ? `${groupCount} ${groupCount === 1 ? "group" : "groups"}` : null, + minerCount > 0 ? `${minerCount} ${minerCount === 1 ? "miner" : "miners"}` : null, + ].filter(Boolean); + + if (parts.length === 0) { + return "Applies to all miners"; + } + + if (parts.length === 1) { + return `Applies to ${parts[0]}`; + } + + return `Applies to ${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`; +}; + +const summarizeWeeklyRecurrence = (daysOfWeek: DayOfWeek[]) => { + const uniqueDays = Array.from(new Set(daysOfWeek)).sort((left, right) => left - right); + + if (uniqueDays.length === 7) { + return "Every day"; + } + + const weekdaySet = new Set([ + DayOfWeek.MONDAY, + DayOfWeek.TUESDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, + DayOfWeek.FRIDAY, + ]); + const weekendSet = new Set([DayOfWeek.SATURDAY, DayOfWeek.SUNDAY]); + + if (uniqueDays.length === weekdaySet.size && uniqueDays.every((day) => weekdaySet.has(day))) { + return "Weekdays"; + } + + if (uniqueDays.length === weekendSet.size && uniqueDays.every((day) => weekendSet.has(day))) { + return "Weekends"; + } + + return uniqueDays + .map((day) => weekdayNames[day]) + .filter(Boolean) + .join(", "); +}; + +const summarizeRecurringPattern = (schedule: Schedule) => { + const recurrence = schedule.recurrence; + + if (!recurrence) { + return "Recurring"; + } + + switch (recurrence.frequency) { + case RecurrenceFrequency.DAILY: + return "Every day"; + case RecurrenceFrequency.WEEKLY: + return summarizeWeeklyRecurrence(recurrence.daysOfWeek); + case RecurrenceFrequency.MONTHLY: + return recurrence.dayOfMonth ? `${formatOrdinal(recurrence.dayOfMonth)} day of month` : "Every month"; + case RecurrenceFrequency.UNSPECIFIED: + default: + return "Recurring"; + } +}; + +const getReferenceDateValue = (schedule: Schedule) => { + if (!schedule.nextRunAt) { + if (schedule.scheduleType === ProtoScheduleType.RECURRING) { + const currentDateParts = getTimeZoneDateTimeParts(new Date(), schedule.timezone); + + if (currentDateParts) { + return formatTimeZoneDateParts(currentDateParts); + } + } + + return schedule.startDate; + } + + const nextRunParts = getTimeZoneDateTimeParts( + toDate(schedule.nextRunAt.seconds, schedule.nextRunAt.nanos), + schedule.timezone, + ); + + return nextRunParts ? formatTimeZoneDateParts(nextRunParts) : schedule.startDate; +}; + +const summarizeTimeWindow = (schedule: Schedule) => { + const referenceDateValue = getReferenceDateValue(schedule); + const startTime = formatTimeValue(schedule.startTime, schedule.timezone, referenceDateValue); + + if (schedule.action !== ProtoScheduleAction.SET_POWER_TARGET || !schedule.endTime) { + return startTime; + } + + const endDateValue = + schedule.endTime < schedule.startTime ? addDaysToDateValue(referenceDateValue, 1) : referenceDateValue; + + return `${startTime} – ${formatTimeValue(schedule.endTime, schedule.timezone, endDateValue)}`; +}; + +const summarizeSchedule = (schedule: Schedule) => { + if (schedule.scheduleType === ProtoScheduleType.ONE_TIME) { + if (schedule.nextRunAt) { + return dateTimeFormatter.format(toDate(schedule.nextRunAt.seconds, schedule.nextRunAt.nanos)); + } + + return formatDateTimeValue(schedule.startDate, schedule.startTime, schedule.timezone); + } + + return `${summarizeRecurringPattern(schedule)} · ${summarizeTimeWindow(schedule)}`; +}; + +const summarizeNextRun = (schedule: Schedule) => { + if (!schedule.nextRunAt) { + return null; + } + + const nextRun = toDate(schedule.nextRunAt.seconds, schedule.nextRunAt.nanos); + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const nextRunDay = new Date(nextRun.getFullYear(), nextRun.getMonth(), nextRun.getDate()); + const dayDifference = Math.round((nextRunDay.getTime() - today.getTime()) / (24 * 60 * 60 * 1000)); + + if (dayDifference === 0) { + return `Runs today at ${timeFormatter.format(nextRun)}`; + } + + if (dayDifference === 1) { + return `Runs tomorrow at ${timeFormatter.format(nextRun)}`; + } + + if (dayDifference > 1 && dayDifference < 7) { + return `Runs ${dayFormatter.format(nextRun)} at ${timeFormatter.format(nextRun)}`; + } + + return `Runs on ${dateTimeFormatter.format(nextRun)}`; +}; + +const summarizeCreatedBy = (schedule: Schedule) => schedule.createdByUsername || schedule.createdBy.toString(); + +const mapSchedule = (schedule: Schedule): ScheduleListItem => ({ + id: schedule.id.toString(), + priority: schedule.priority, + name: schedule.name, + targetSummary: summarizeTargets(schedule), + scheduleSummary: summarizeSchedule(schedule), + nextRunSummary: summarizeNextRun(schedule), + action: mapAction(schedule), + status: mapStatus(schedule.status), + createdBy: summarizeCreatedBy(schedule), + rawSchedule: schedule, +}); + +const updateMappedSchedule = (schedules: ScheduleListItem[], schedule: Schedule) => + normalizeSchedules( + schedules.map((current) => (current.id === schedule.id.toString() ? mapSchedule(schedule) : current)), + ); + +export const useScheduleApi = () => { + const { handleAuthErrors } = useAuthErrors(); + const [schedules, setSchedules] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const inFlightRefreshRef = useRef | null>(null); + const foregroundRefreshCountRef = useRef(0); + + const runListSchedules = useCallback(() => { + if (inFlightRefreshRef.current) { + return inFlightRefreshRef.current; + } + + const requestPromise = (async () => { + try { + const scheduleResponse = await scheduleClient.listSchedules(create(ListSchedulesRequestSchema, {})); + const mappedSchedules = normalizeSchedules(scheduleResponse.schedules.map((schedule) => mapSchedule(schedule))); + + setSchedules(mappedSchedules); + return mappedSchedules; + } catch (error) { + const resolvedError = ensureError(error, "Failed to load schedules."); + + handleAuthErrors({ + error, + onError: () => { + throw resolvedError; + }, + }); + + throw resolvedError; + } + })(); + + inFlightRefreshRef.current = requestPromise; + + void requestPromise.then( + () => { + if (inFlightRefreshRef.current === requestPromise) { + inFlightRefreshRef.current = null; + } + }, + () => { + if (inFlightRefreshRef.current === requestPromise) { + inFlightRefreshRef.current = null; + } + }, + ); + + return requestPromise; + }, [handleAuthErrors]); + + const listSchedules = useCallback( + async ({ background = false }: RefreshSchedulesOptions = {}) => { + if (background) { + return runListSchedules(); + } + + foregroundRefreshCountRef.current += 1; + setIsLoading(true); + + try { + return await runListSchedules(); + } finally { + foregroundRefreshCountRef.current = Math.max(0, foregroundRefreshCountRef.current - 1); + setIsLoading(foregroundRefreshCountRef.current > 0); + } + }, + [runListSchedules], + ); + + const refreshSchedules = useCallback( + async (options?: RefreshSchedulesOptions) => listSchedules(options), + [listSchedules], + ); + + const pauseSchedule = useCallback( + async (scheduleId: string) => { + try { + const response = await scheduleClient.pauseSchedule( + create(PauseScheduleRequestSchema, { scheduleId: BigInt(scheduleId) }), + ); + const nextSchedule = response.schedule; + + if (!nextSchedule) { + throw new Error("Paused schedule response was missing a schedule."); + } + + setSchedules((current) => updateMappedSchedule(current, nextSchedule)); + emitSchedulesChanged(); + } catch (error) { + const resolvedError = ensureError(error, "Failed to pause schedule."); + + handleAuthErrors({ + error, + onError: () => { + throw resolvedError; + }, + }); + + throw resolvedError; + } + }, + [handleAuthErrors], + ); + + const resumeSchedule = useCallback( + async (scheduleId: string) => { + try { + const response = await scheduleClient.resumeSchedule( + create(ResumeScheduleRequestSchema, { scheduleId: BigInt(scheduleId) }), + ); + const nextSchedule = response.schedule; + + if (!nextSchedule) { + throw new Error("Resumed schedule response was missing a schedule."); + } + + setSchedules((current) => updateMappedSchedule(current, nextSchedule)); + emitSchedulesChanged(); + } catch (error) { + const resolvedError = ensureError(error, "Failed to resume schedule."); + + handleAuthErrors({ + error, + onError: () => { + throw resolvedError; + }, + }); + + throw resolvedError; + } + }, + [handleAuthErrors], + ); + + const deleteSchedule = useCallback( + async (scheduleId: string) => { + try { + await scheduleClient.deleteSchedule(create(DeleteScheduleRequestSchema, { scheduleId: BigInt(scheduleId) })); + setSchedules((current) => normalizeSchedules(current.filter((schedule) => schedule.id !== scheduleId))); + emitSchedulesChanged(); + } catch (error) { + const resolvedError = ensureError(error, "Failed to delete schedule."); + + handleAuthErrors({ + error, + onError: () => { + throw resolvedError; + }, + }); + + throw resolvedError; + } + }, + [handleAuthErrors], + ); + + const reorderSchedules = useCallback( + async (scheduleIds: string[]) => { + try { + const request: ReorderSchedulesRequest = create(ReorderSchedulesRequestSchema, { + scheduleIds: scheduleIds.map((id) => BigInt(id)), + }); + + await scheduleClient.reorderSchedules(request); + + setSchedules((current) => { + const rank = new Map(scheduleIds.map((id, index) => [id, index])); + const fallbackRank = scheduleIds.length; + + return resequenceSchedules( + [...current].sort((left, right) => { + const leftRank = rank.get(left.id) ?? fallbackRank + left.priority; + const rightRank = rank.get(right.id) ?? fallbackRank + right.priority; + + return leftRank - rightRank; + }), + ); + }); + emitSchedulesChanged(); + } catch (error) { + const resolvedError = ensureError(error, "Failed to reorder schedules."); + + handleAuthErrors({ + error, + onError: () => { + throw resolvedError; + }, + }); + + throw resolvedError; + } + }, + [handleAuthErrors], + ); + + const createSchedule = useCallback( + async (request: CreateScheduleRequest) => { + try { + const response = await scheduleClient.createSchedule(request); + const nextSchedule = response.schedule; + + if (!nextSchedule) { + throw new Error("Created schedule response was missing a schedule."); + } + + const mappedSchedule = mapSchedule(nextSchedule); + setSchedules((current) => normalizeSchedules([...current, mappedSchedule])); + emitSchedulesChanged(); + return mappedSchedule; + } catch (error) { + const resolvedError = ensureError(error, "Failed to create schedule."); + + handleAuthErrors({ + error, + onError: () => { + throw resolvedError; + }, + }); + + throw resolvedError; + } + }, + [handleAuthErrors], + ); + + const updateSchedule = useCallback( + async (request: UpdateScheduleRequest) => { + try { + const response = await scheduleClient.updateSchedule(request); + const nextSchedule = response.schedule; + + if (!nextSchedule) { + throw new Error("Updated schedule response was missing a schedule."); + } + + setSchedules((current) => updateMappedSchedule(current, nextSchedule)); + emitSchedulesChanged(); + return mapSchedule(nextSchedule); + } catch (error) { + const resolvedError = ensureError(error, "Failed to update schedule."); + + handleAuthErrors({ + error, + onError: () => { + throw resolvedError; + }, + }); + + throw resolvedError; + } + }, + [handleAuthErrors], + ); + + return useMemo( + () => ({ + schedules, + isLoading, + listSchedules, + refreshSchedules, + createSchedule, + updateSchedule, + pauseSchedule, + resumeSchedule, + deleteSchedule, + reorderSchedules, + }), + [ + schedules, + isLoading, + listSchedules, + refreshSchedules, + createSchedule, + updateSchedule, + pauseSchedule, + resumeSchedule, + deleteSchedule, + reorderSchedules, + ], + ); +}; + +export type UseScheduleApiResult = ReturnType; + +export default useScheduleApi; diff --git a/client/src/protoFleet/api/useTelemetryMetrics.test.ts b/client/src/protoFleet/api/useTelemetryMetrics.test.ts new file mode 100644 index 000000000..68b0c4c05 --- /dev/null +++ b/client/src/protoFleet/api/useTelemetryMetrics.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { getGranularityForDuration } from "@/protoFleet/features/dashboard/utils/granularity"; +import { FleetDuration } from "@/shared/components/DurationSelector"; + +// Import the constants and function we need to test +// Since they're not exported, we'll need to test through the hook's behavior +// But for unit testing the logic, let's test the duration calculations directly + +describe("useTelemetryMetrics granularity calculations", () => { + // Helper to calculate expected bucket count + const calculateBucketCount = (durationSeconds: number, granularitySeconds: number): number => { + return Math.ceil(durationSeconds / granularitySeconds); + }; + + describe("duration to seconds conversion", () => { + it("converts 1h to 3600 seconds", () => { + const duration: FleetDuration = "1h"; + const seconds = parseInt(duration.slice(0, -1)) * 3600; + expect(seconds).toBe(3600); + }); + + it("converts 24h to 86400 seconds", () => { + const duration: FleetDuration = "24h"; + const seconds = parseInt(duration.slice(0, -1)) * 3600; + expect(seconds).toBe(86400); + }); + + it("converts 7d to 604800 seconds", () => { + const duration: FleetDuration = "7d"; + const seconds = parseInt(duration.slice(0, -1)) * 24 * 3600; + expect(seconds).toBe(604800); + }); + + it("converts 30d to 2592000 seconds", () => { + const duration: FleetDuration = "30d"; + const seconds = parseInt(duration.slice(0, -1)) * 24 * 3600; + expect(seconds).toBe(2592000); + }); + }); + + describe("granularity selection to stay within 1000 bucket limit", () => { + const BACKEND_BUCKET_LIMIT = 1000; + + it("uses 90s granularity for 1h (40 buckets)", () => { + const durationSeconds = 3600; // 1h + const granularity = 90; + const buckets = calculateBucketCount(durationSeconds, granularity); + + expect(buckets).toBe(40); + expect(buckets).toBeLessThanOrEqual(BACKEND_BUCKET_LIMIT); + }); + + it("uses 90s granularity for 24h (960 buckets)", () => { + const durationSeconds = 86400; // 24h + const granularity = 90; + const buckets = calculateBucketCount(durationSeconds, granularity); + + expect(buckets).toBe(960); + expect(buckets).toBeLessThanOrEqual(BACKEND_BUCKET_LIMIT); + }); + + it("uses 900s granularity for 7d (672 buckets)", () => { + const durationSeconds = 604800; // 7d + const granularity = 900; + const buckets = calculateBucketCount(durationSeconds, granularity); + + expect(buckets).toBe(672); + expect(buckets).toBeLessThanOrEqual(BACKEND_BUCKET_LIMIT); + }); + + it("uses 2700s (45min) granularity for 30d (960 buckets)", () => { + const durationSeconds = 2592000; // 30d + const granularity = 2700; + const buckets = calculateBucketCount(durationSeconds, granularity); + + expect(buckets).toBe(960); + expect(buckets).toBeLessThanOrEqual(BACKEND_BUCKET_LIMIT); + }); + }); + + describe("granularity would exceed limit with wrong values", () => { + it("7d with 600s granularity would exceed limit (1008 buckets)", () => { + const durationSeconds = 604800; // 7d + const wrongGranularity = 600; + const buckets = calculateBucketCount(durationSeconds, wrongGranularity); + + expect(buckets).toBe(1008); + expect(buckets).toBeGreaterThan(1000); // Would fail without optimization + }); + + it("30d with 90s granularity would exceed limit (28800 buckets)", () => { + const durationSeconds = 2592000; // 30d + const wrongGranularity = 90; + const buckets = calculateBucketCount(durationSeconds, wrongGranularity); + + expect(buckets).toBe(28800); + expect(buckets).toBeGreaterThan(1000); // Would fail without optimization + }); + }); + + describe("edge cases", () => { + it("keeps 7d granularity aligned with hourly aggregates", () => { + const granularity = getGranularityForDuration("7d"); + + expect(granularity).toBe(900); + expect(3600 % granularity).toBe(0); + }); + + it("invalid duration returns default granularity (90s)", () => { + // This tests that invalid durations fall back to 90s default + const defaultGranularity = 90; + expect(defaultGranularity).toBe(90); + }); + }); +}); diff --git a/client/src/protoFleet/api/useTelemetryMetrics.ts b/client/src/protoFleet/api/useTelemetryMetrics.ts new file mode 100644 index 000000000..5e12cb72f --- /dev/null +++ b/client/src/protoFleet/api/useTelemetryMetrics.ts @@ -0,0 +1,148 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { create } from "@bufbuild/protobuf"; +import { telemetryClient } from "@/protoFleet/api/clients"; +import { + AggregationType, + DeviceListSchema, + DeviceSelectorSchema, + GetCombinedMetricsRequestSchema, + GetCombinedMetricsResponse, + MeasurementType, +} from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { getGranularityForDuration } from "@/protoFleet/features/dashboard/utils/granularity"; +import { useAuthErrors } from "@/protoFleet/store"; +import { type FleetDuration, getFleetDurationMs } from "@/shared/components/DurationSelector"; + +interface TelemetryMetricsOptions { + deviceIds?: string[]; + measurementTypes?: MeasurementType[]; + aggregations?: AggregationType[]; + duration: FleetDuration; + enabled?: boolean; + pollIntervalMs?: number; +} + +export const useTelemetryMetrics = (options: TelemetryMetricsOptions) => { + const { handleAuthErrors } = useAuthErrors(); + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [hasLoaded, setHasLoaded] = useState(false); + const [error, setError] = useState(null); + + const requestIdRef = useRef(0); + const hasLoadedRef = useRef(false); + + // Reset when scope changes — invalidate in-flight requests so stale responses can't land + const scopeKey = `${options.duration}-${options.deviceIds?.join(",") ?? "all"}`; + const prevScopeRef = useRef(scopeKey); + if (prevScopeRef.current !== scopeKey) { + prevScopeRef.current = scopeKey; + ++requestIdRef.current; + hasLoadedRef.current = false; + setHasLoaded(false); + setData(null); + } + + const fetchMetrics = useCallback(async () => { + if (!options.enabled) { + ++requestIdRef.current; + setIsLoading(false); + return; + } + + const thisRequestId = ++requestIdRef.current; + + // Only show loading spinner on first fetch, not poll refreshes + if (!hasLoadedRef.current) { + setIsLoading(true); + } + setError(null); + + try { + const now = new Date(); + const durationMs = getFleetDurationMs(options.duration); + const startTime = new Date(now.getTime() - durationMs); + + const request = create(GetCombinedMetricsRequestSchema, { + deviceSelector: options.deviceIds?.length + ? create(DeviceSelectorSchema, { + selectorValue: { + case: "deviceList", + value: create(DeviceListSchema, { + deviceIds: options.deviceIds, + }), + }, + }) + : create(DeviceSelectorSchema, { + selectorValue: { case: "allDevices", value: true }, + }), + measurementTypes: options.measurementTypes || [MeasurementType.HASHRATE], + aggregations: options.aggregations || [AggregationType.AVERAGE], + granularity: { seconds: BigInt(getGranularityForDuration(options.duration)), nanos: 0 }, + startTime: { + seconds: BigInt(Math.floor(startTime.getTime() / 1000)), + nanos: 0, + }, + endTime: { + seconds: BigInt(Math.floor(now.getTime() / 1000)), + nanos: 0, + }, + pageSize: 10000, + pageToken: "", + }); + + const response = await telemetryClient.getCombinedMetrics(request); + + // Discard stale responses + if (thisRequestId !== requestIdRef.current) return; + + setData(response); + hasLoadedRef.current = true; + setHasLoaded(true); + } catch (err) { + if (thisRequestId !== requestIdRef.current) return; + + handleAuthErrors({ + error: err, + onError: () => { + const errorObj = err instanceof Error ? err : new Error(String(err)); + setError(errorObj); + // Only clear data on first-load failure; preserve last snapshot during poll errors + if (!hasLoadedRef.current) { + setData(null); + } + console.error("Error fetching combined metrics:", errorObj); + }, + }); + } finally { + if (thisRequestId === requestIdRef.current) { + setIsLoading(false); + } + } + }, [ + options.deviceIds, + options.measurementTypes, + options.aggregations, + options.duration, + options.enabled, + handleAuthErrors, + ]); + + // Initial fetch + refetch on dependency change + useEffect(() => { + fetchMetrics(); + }, [fetchMetrics]); + + // Polling + useEffect(() => { + if (!options.pollIntervalMs || !options.enabled) return; + + const intervalId = setInterval(() => { + void fetchMetrics(); + }, options.pollIntervalMs); + + return () => clearInterval(intervalId); + }, [options.pollIntervalMs, options.enabled, fetchMetrics]); + + return { data, isLoading, hasLoaded, error, refetch: fetchMetrics }; +}; diff --git a/client/src/protoFleet/api/useUpdateWorkerNames.test.ts b/client/src/protoFleet/api/useUpdateWorkerNames.test.ts new file mode 100644 index 000000000..0169924d1 --- /dev/null +++ b/client/src/protoFleet/api/useUpdateWorkerNames.test.ts @@ -0,0 +1,125 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import { fleetManagementClient } from "./clients"; +import useUpdateWorkerNames from "./useUpdateWorkerNames"; +import { DeviceIdentifierListSchema } from "@/protoFleet/api/generated/common/v1/device_selector_pb"; +import { SortConfigSchema, SortDirection, SortField } from "@/protoFleet/api/generated/common/v1/sort_pb"; +import { + DeviceSelectorSchema, + MinerNameConfigSchema, + NamePropertySchema, + StringPropertySchema, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +vi.mock("./clients", () => ({ + fleetManagementClient: { + updateWorkerNames: vi.fn(), + }, +})); + +const mockHandleAuthErrors = vi.fn(); + +vi.mock("@/protoFleet/store", () => ({ + useAuthErrors: vi.fn(() => ({ + handleAuthErrors: mockHandleAuthErrors, + })), +})); + +describe("useUpdateWorkerNames", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("sends the expected request payload and wraps the sort in an array", async () => { + vi.mocked(fleetManagementClient.updateWorkerNames).mockResolvedValue({ updatedCount: 1 } as never); + + const deviceSelector = create(DeviceSelectorSchema, { + selectionType: { + case: "includeDevices", + value: create(DeviceIdentifierListSchema, { deviceIdentifiers: ["miner-1", "miner-2"] }), + }, + }); + const nameConfig = create(MinerNameConfigSchema, { + properties: [ + create(NamePropertySchema, { + kind: { + case: "stringValue", + value: create(StringPropertySchema, { value: "worker-new" }), + }, + }), + ], + separator: "", + }); + const sort = create(SortConfigSchema, { + field: SortField.NAME, + direction: SortDirection.ASC, + }); + + const { result } = renderHook(() => useUpdateWorkerNames()); + + await act(async () => { + await result.current.updateWorkerNames(deviceSelector, nameConfig, "fleet-user", "fleet-pass", sort); + }); + + expect(fleetManagementClient.updateWorkerNames).toHaveBeenCalledWith( + expect.objectContaining({ + deviceSelector, + nameConfig, + sort: [sort], + userUsername: "fleet-user", + userPassword: "fleet-pass", + }), + ); + }); + + it("sends an empty sort array when no sort is provided", async () => { + vi.mocked(fleetManagementClient.updateWorkerNames).mockResolvedValue({ updatedCount: 1 } as never); + + const deviceSelector = create(DeviceSelectorSchema, { + selectionType: { + case: "includeDevices", + value: create(DeviceIdentifierListSchema, { deviceIdentifiers: ["miner-1"] }), + }, + }); + const nameConfig = create(MinerNameConfigSchema, { + separator: "", + }); + + const { result } = renderHook(() => useUpdateWorkerNames()); + + await act(async () => { + await result.current.updateWorkerNames(deviceSelector, nameConfig, "fleet-user", "fleet-pass"); + }); + + expect(fleetManagementClient.updateWorkerNames).toHaveBeenCalledWith( + expect.objectContaining({ + sort: [], + }), + ); + }); + + it("handles auth errors and rethrows the original error", async () => { + const testError = new Error("request failed"); + vi.mocked(fleetManagementClient.updateWorkerNames).mockRejectedValue(testError); + + const deviceSelector = create(DeviceSelectorSchema, { + selectionType: { + case: "includeDevices", + value: create(DeviceIdentifierListSchema, { deviceIdentifiers: ["miner-1"] }), + }, + }); + const nameConfig = create(MinerNameConfigSchema, { + separator: "", + }); + + const { result } = renderHook(() => useUpdateWorkerNames()); + + await expect( + result.current.updateWorkerNames(deviceSelector, nameConfig, "fleet-user", "fleet-pass"), + ).rejects.toThrow(testError); + expect(mockHandleAuthErrors).toHaveBeenCalledWith({ + error: testError, + }); + }); +}); diff --git a/client/src/protoFleet/api/useUpdateWorkerNames.ts b/client/src/protoFleet/api/useUpdateWorkerNames.ts new file mode 100644 index 000000000..ee9ebb97b --- /dev/null +++ b/client/src/protoFleet/api/useUpdateWorkerNames.ts @@ -0,0 +1,79 @@ +import { useCallback, useMemo } from "react"; +import { create } from "@bufbuild/protobuf"; + +import { fleetManagementClient } from "@/protoFleet/api/clients"; +import { DeviceIdentifierListSchema } from "@/protoFleet/api/generated/common/v1/device_selector_pb"; +import { type SortConfig } from "@/protoFleet/api/generated/common/v1/sort_pb"; +import { + type DeviceSelector, + DeviceSelectorSchema, + type MinerNameConfig, + MinerNameConfigSchema, + NamePropertySchema, + StringPropertySchema, + UpdateWorkerNamesRequestSchema, + type UpdateWorkerNamesResponse, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { useAuthErrors } from "@/protoFleet/store"; + +const useUpdateWorkerNames = () => { + const { handleAuthErrors } = useAuthErrors(); + + const updateWorkerNames = useCallback( + async ( + deviceSelector: DeviceSelector, + nameConfig: MinerNameConfig, + userUsername: string, + userPassword: string, + sort?: SortConfig, + ): Promise => { + try { + return await fleetManagementClient.updateWorkerNames( + create(UpdateWorkerNamesRequestSchema, { + deviceSelector, + nameConfig, + sort: sort ? [sort] : [], + userUsername, + userPassword, + }), + ); + } catch (err) { + handleAuthErrors({ + error: err, + }); + throw err; + } + }, + [handleAuthErrors], + ); + + const updateSingleWorkerName = useCallback( + async (deviceIdentifier: string, name: string, userUsername: string, userPassword: string) => + updateWorkerNames( + create(DeviceSelectorSchema, { + selectionType: { + case: "includeDevices", + value: create(DeviceIdentifierListSchema, { deviceIdentifiers: [deviceIdentifier] }), + }, + }), + create(MinerNameConfigSchema, { + properties: [ + create(NamePropertySchema, { + kind: { + case: "stringValue", + value: create(StringPropertySchema, { value: name }), + }, + }), + ], + separator: "", + }), + userUsername, + userPassword, + ), + [updateWorkerNames], + ); + + return useMemo(() => ({ updateWorkerNames, updateSingleWorkerName }), [updateSingleWorkerName, updateWorkerNames]); +}; + +export default useUpdateWorkerNames; diff --git a/client/src/protoFleet/api/useUserManagement.ts b/client/src/protoFleet/api/useUserManagement.ts new file mode 100644 index 000000000..340bc412f --- /dev/null +++ b/client/src/protoFleet/api/useUserManagement.ts @@ -0,0 +1,163 @@ +import { useCallback } from "react"; + +import { authClient } from "@/protoFleet/api/clients"; +import type { + CreateUserRequest, + DeactivateUserRequest, + ResetUserPasswordRequest, +} from "@/protoFleet/api/generated/auth/v1/auth_pb"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { useAuthErrors } from "@/protoFleet/store"; + +interface CreateUserProps { + username: CreateUserRequest["username"]; + onSuccess?: (userId: string, username: string, tempPassword: string) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface ListUsersProps { + onSuccess?: ( + users: Array<{ + userId: string; + username: string; + passwordUpdatedAt: Date | null; + lastLoginAt: Date | null; + role: string; + requiresPasswordChange: boolean; + }>, + ) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface ResetUserPasswordProps { + userId: ResetUserPasswordRequest["userId"]; + onSuccess?: (tempPassword: string) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface DeactivateUserProps { + userId: DeactivateUserRequest["userId"]; + onSuccess?: () => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +const useUserManagement = () => { + const { handleAuthErrors } = useAuthErrors(); + + const createUser = useCallback( + async ({ username, onSuccess, onError, onFinally }: CreateUserProps) => { + await authClient + .createUser({ username }) + .then((response) => { + onSuccess?.(response.userId, response.username, response.temporaryPassword); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }) + .finally(() => { + onFinally?.(); + }); + }, + [handleAuthErrors], + ); + + const listUsers = useCallback( + async ({ onSuccess, onError, onFinally }: ListUsersProps) => { + await authClient + .listUsers({}) + .then((response) => { + const users = response.users.map((user) => ({ + userId: user.userId, + username: user.username, + passwordUpdatedAt: + user.passwordUpdatedAt && user.passwordUpdatedAt.seconds > 0 + ? new Date(Number(user.passwordUpdatedAt.seconds) * 1000) + : null, + lastLoginAt: + user.lastLoginAt && user.lastLoginAt.seconds > 0 + ? new Date(Number(user.lastLoginAt.seconds) * 1000) + : null, + role: user.role, + requiresPasswordChange: user.requiresPasswordChange, + })); + onSuccess?.(users); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }) + .finally(() => { + onFinally?.(); + }); + }, + [handleAuthErrors], + ); + + const resetUserPassword = useCallback( + async ({ userId, onSuccess, onError, onFinally }: ResetUserPasswordProps) => { + await authClient + .resetUserPassword({ userId }) + .then((response) => { + onSuccess?.(response.temporaryPassword); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }) + .finally(() => { + onFinally?.(); + }); + }, + [handleAuthErrors], + ); + + const deactivateUser = useCallback( + async ({ userId, onSuccess, onError, onFinally }: DeactivateUserProps) => { + await authClient + .deactivateUser({ userId }) + .then(() => { + onSuccess?.(); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }) + .finally(() => { + onFinally?.(); + }); + }, + [handleAuthErrors], + ); + + return { + createUser, + listUsers, + resetUserPassword, + deactivateUser, + }; +}; + +export type UseUserManagementReturn = ReturnType; + +export { useUserManagement }; diff --git a/client/src/protoFleet/components/App/App.test.tsx b/client/src/protoFleet/components/App/App.test.tsx new file mode 100644 index 000000000..a51f9a017 --- /dev/null +++ b/client/src/protoFleet/components/App/App.test.tsx @@ -0,0 +1,255 @@ +import { ReactNode } from "react"; +import { createMemoryRouter, RouterProvider } from "react-router-dom"; +import { render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import App from "./App"; +import { FleetOnboardingStatus } from "@/protoFleet/api/generated/onboarding/v1/onboarding_pb"; +// TODO: Update this test to work with Zustand store instead of React Context + +// Mock the API call for onboarding status +let mockedOnboardingStatus: FleetOnboardingStatus | null = null; +vi.mock("@/protoFleet/api/useOnboardedStatus", () => ({ + useOnboardedStatus: vi.fn(() => ({ + poolConfigured: mockedOnboardingStatus?.poolConfigured ?? false, + devicePaired: mockedOnboardingStatus?.devicePaired ?? false, + statusLoaded: true, + refetch: vi.fn(() => Promise.resolve(mockedOnboardingStatus)), + })), +})); + +// Mock AppLayout component for UI testing +vi.mock("@/protoFleet/components/AppLayout", () => ({ + default: ({ children }: { children: ReactNode }) => ( +
+
App Layout Header
+ {children} +
+ ), +})); + +vi.mock("@/protoFleet/routes", () => ({ + getRouteMetadata: vi.fn((pathname) => ({ + title: pathname === "/auth" ? "Auth" : pathname.includes("onboarding") ? "Onboarding" : "Home", + requireAuth: pathname !== "/auth" && !pathname.includes("/welcome"), + useAppLayout: !pathname.includes("/auth") && !pathname.includes("/onboarding"), + })), +})); + +// Global test state for auth token validity +// TODO: Re-enable when tests are updated to work with Zustand +// let isValidToken = true; + +// TODO: Update this test to work with Zustand store instead of React Context +describe.skip("App", () => { + const createRoutes = () => [ + { + path: "/", + element: , + children: [ + { + index: true, + element:
Home Page Content
, + }, + { + path: "auth", + element:
Auth Page Content
, + }, + { + path: "miners", + element:
Miners Page Content
, + }, + { + path: "welcome", + element:
Landing Page Content
, + }, + { + path: "onboarding/miners", + element:
Miners Onboarding Page
, + }, + { + path: "onboarding/mining-pool", + element:
Mining Pool Page Content
, + }, + ], + }, + ]; + + // Setup function to render the app with a router + const renderWithRouter = (initialPath = "/") => { + const router = createMemoryRouter(createRoutes(), { + initialEntries: [initialPath], + }); + + // TODO: Create the auth context with the test token state + // This needs to be updated to work with Zustand store + return render( + // + , + // , + ); + }; + + beforeEach(() => { + vi.clearAllMocks(); + // isValidToken = true; + mockedOnboardingStatus = null; + }); + + describe("Authentication routing", () => { + test("should allow access to protected routes with valid token", async () => { + // isValidToken = true; + renderWithRouter("/"); + + // Should show home page with valid token + await waitFor(() => { + expect(screen.getByTestId("home-page")).toBeInTheDocument(); + }); + + // Home page should use AppLayout + expect(screen.getByTestId("app-layout")).toBeInTheDocument(); + }); + + test("should redirect to auth page with invalid token", async () => { + // isValidToken = false; + renderWithRouter("/"); + + // Should redirect to auth page with invalid token + await waitFor(() => { + expect(screen.getByTestId("auth-page")).toBeInTheDocument(); + }); + }); + + test("should always allow access to auth page regardless of token", async () => { + // isValidToken = false; + renderWithRouter("/auth"); + + // Should not redirect when already on auth page + await waitFor(() => { + expect(screen.getByTestId("auth-page")).toBeInTheDocument(); + }); + }); + }); + + // describe("Onboarding routing", () => { + // test("should redirect to miners onboarding when devicePaired is false", async () => { + // isValidToken = true; + // mockedOnboardingStatus = { + // devicePaired: false, + // poolConfigured: false, + // } as FleetOnboardingStatus; + + // renderWithRouter("/"); + + // // Should redirect to onboarding/miners + // await waitFor(() => { + // expect( + // screen.getByTestId("onboarding-miners-page"), + // ).toBeInTheDocument(); + // }); + // }); + + // test("should redirect to mining-pool onboarding when poolConfigured is false", async () => { + // isValidToken = true; + // mockedOnboardingStatus = { + // devicePaired: true, + // poolConfigured: false, + // } as FleetOnboardingStatus; + + // renderWithRouter("/"); + + // // Should redirect to onboarding/mining-pool + // await waitFor(() => { + // expect(screen.getByTestId("mining-pool-page")).toBeInTheDocument(); + // }); + // }); + + // test("should not redirect when onboarding is complete", async () => { + // isValidToken = true; + // mockedOnboardingStatus = { + // devicePaired: true, + // poolConfigured: true, + // } as FleetOnboardingStatus; + + // renderWithRouter("/"); + + // // Should remain on home page + // await waitFor(() => { + // expect(screen.getByTestId("home-page")).toBeInTheDocument(); + // }); + // }); + + // test("should not redirect when onboarding status is still loading", async () => { + // isValidToken = true; + // mockedOnboardingStatus = null; // Loading state + + // renderWithRouter("/"); + + // // Should remain on home page + // await waitFor(() => { + // expect(screen.getByTestId("home-page")).toBeInTheDocument(); + // }); + // }); + // }); + + // describe("Combined auth and onboarding behavior", () => { + // test("should prioritize auth redirect over onboarding redirect", async () => { + // isValidToken = false; + // mockedOnboardingStatus = { + // devicePaired: false, + // poolConfigured: true, + // } as FleetOnboardingStatus; + + // renderWithRouter("/"); + + // // Should redirect to auth page, not to onboarding page + // await waitFor(() => { + // expect(screen.getByTestId("auth-page")).toBeInTheDocument(); + // }); + // }); + + // test("should process onboarding after successful auth", async () => { + // // First render with invalid token + // isValidToken = false; + // renderWithRouter("/"); + + // // Should redirect to auth + // await waitFor(() => { + // expect(screen.getByTestId("auth-page")).toBeInTheDocument(); + // }); + + // // Now simulate login success and onboarding check + // isValidToken = true; + // mockedOnboardingStatus = { + // devicePaired: false, + // poolConfigured: false, + // } as FleetOnboardingStatus; + + // // Re-render with the updated state + // renderWithRouter("/"); + + // // Should now redirect to onboarding + // await waitFor(() => { + // expect( + // screen.getByTestId("onboarding-miners-page"), + // ).toBeInTheDocument(); + // }); + // }); + // }); +}); diff --git a/client/src/protoFleet/components/App/App.tsx b/client/src/protoFleet/components/App/App.tsx new file mode 100644 index 000000000..22a3c0c35 --- /dev/null +++ b/client/src/protoFleet/components/App/App.tsx @@ -0,0 +1,129 @@ +import { ReactNode, useEffect, useMemo, useRef } from "react"; +import { useMatches } from "react-router-dom"; +import clsx from "clsx"; + +import { onboardingClient } from "@/protoFleet/api/clients"; +import AppLayout from "@/protoFleet/components/AppLayout"; +import { requiresAuth } from "@/protoFleet/router"; +import { useCheckAuthentication, useIsActionBarVisible } from "@/protoFleet/store"; +import { useDeviceTheme, useSetDeviceTheme, useTheme } from "@/protoFleet/store"; +import ErrorBoundary from "@/shared/components/ErrorBoundary"; +import ProgressCircular from "@/shared/components/ProgressCircular"; +import { useApplyTheme } from "@/shared/features/preferences"; +import { Toaster } from "@/shared/features/toaster"; +import { isBackendDownError } from "@/shared/utils/backendHealth"; +import { redirectFromFleetDown } from "@/shared/utils/fleetDownRedirect"; + +interface AppProps { + children?: ReactNode; + fullscreen?: boolean; +} + +const App = ({ children, fullscreen }: AppProps) => { + // ============================================================================ + // BACKEND HEALTH CHECK + // ============================================================================ + const healthCheckDone = useRef(false); + + useEffect(() => { + // Only run health check once on initial mount + if (healthCheckDone.current) return; + healthCheckDone.current = true; + + const isOnFleetDownErrorPage = window.location.pathname === "/fleet-down"; + let isMounted = true; + + // Check if backend is available by making a lightweight API call + const checkBackendHealth = async () => { + try { + await onboardingClient.getFleetInitStatus({}); + + // If backend is up and we're on the error page, redirect back to app + if (isOnFleetDownErrorPage && isMounted) { + redirectFromFleetDown(); + } + } catch (error: unknown) { + // Only redirect to error page if backend is down AND not already on error page + if (isBackendDownError(error) && !isOnFleetDownErrorPage && isMounted) { + const currentPath = window.location.pathname + window.location.search + window.location.hash; + window.location.href = `/fleet-down?from=${encodeURIComponent(currentPath)}`; + } + } + }; + + checkBackendHealth(); + + return () => { + isMounted = false; + }; + }, []); + + // ============================================================================ + // THEME APPLICATION + // ============================================================================ + const theme = useTheme(); + const deviceTheme = useDeviceTheme(); + const setDeviceTheme = useSetDeviceTheme(); + + // Apply theme effects on mount + useApplyTheme({ theme, deviceTheme, setDeviceTheme }); + + // ============================================================================ + // AUTH CHECKING + // ============================================================================ + const matches = useMatches(); + const currentPath = useMemo(() => { + return matches[matches.length - 1]?.pathname || "/"; + }, [matches]); + + const requireAuth = useMemo(() => { + // Check if this specific path is configured to not require auth + // If not in the config, default to requiring auth + return requiresAuth[currentPath] !== false; + }, [currentPath]); + + const { loading, hasAccess } = useCheckAuthentication(requireAuth); + + const isActionBarVisible = useIsActionBarVisible(); + + // Show loading spinner ONLY if auth is required AND (loading OR access denied) + const showLoading = requireAuth && (loading || hasAccess !== true); + + // ============================================================================ + // LOADING STATE + // ============================================================================ + if (showLoading) { + return ( +
+ +
+ ); + } + + // ============================================================================ + // RENDER + // ============================================================================ + return ( + + {/* Toaster - Fixed position, renders above overlays (z-50) and dialogs (z-40) */} +
+ +
+ + {fullscreen ? ( + // Fullscreen mode: Just render children without AppLayout chrome + children + ) : ( + // Normal mode: Render with AppLayout + {children} + )} +
+ ); +}; + +export default App; diff --git a/client/src/protoFleet/components/App/index.ts b/client/src/protoFleet/components/App/index.ts new file mode 100644 index 000000000..7078f6de4 --- /dev/null +++ b/client/src/protoFleet/components/App/index.ts @@ -0,0 +1,3 @@ +import App from "./App"; + +export default App; diff --git a/client/src/protoFleet/components/AppLayout/AppLayout.test.tsx b/client/src/protoFleet/components/AppLayout/AppLayout.test.tsx new file mode 100644 index 000000000..9da54785f --- /dev/null +++ b/client/src/protoFleet/components/AppLayout/AppLayout.test.tsx @@ -0,0 +1,74 @@ +import type { ReactNode } from "react"; +import { MemoryRouter } from "react-router-dom"; +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import AppLayout from "./AppLayout"; +import type { UseSchedulePillDataResult } from "@/protoFleet/components/PageHeader/useSchedulePillData"; + +const mockUseWindowDimensions = vi.fn(); +const mockUseReactiveLocalStorage = vi.fn(); +const mockUseSchedulePillData = vi.fn(); + +vi.mock("@/protoFleet/api/ScheduleApiProvider", () => ({ + ScheduleApiProvider: ({ children }: { children: ReactNode }) => <>{children}, +})); + +vi.mock("@/protoFleet/components/NavigationMenu", () => ({ + __esModule: true, + default: () =>
Navigation menu
, +})); + +vi.mock("@/protoFleet/components/PageHeader", () => ({ + __esModule: true, + default: () =>
Page header
, +})); + +vi.mock("@/protoFleet/components/PageHeader/useSchedulePillData", () => ({ + useSchedulePillData: () => mockUseSchedulePillData(), +})); + +vi.mock("@/shared/hooks/useWindowDimensions", () => ({ + useWindowDimensions: () => mockUseWindowDimensions(), +})); + +vi.mock("@/shared/hooks/useReactiveLocalStorage", () => ({ + useReactiveLocalStorage: () => mockUseReactiveLocalStorage(), +})); + +const createSchedulePillData = (overrides: Partial = {}): UseSchedulePillDataResult => ({ + hasVisibleSchedules: false, + pillSchedule: null, + sections: [], + pendingScheduleId: null, + onToggleScheduleStatus: vi.fn(), + ...overrides, +}); + +describe("AppLayout", () => { + beforeEach(() => { + mockUseWindowDimensions.mockReturnValue({ + isPhone: true, + }); + mockUseReactiveLocalStorage.mockReturnValue([false, vi.fn()]); + mockUseSchedulePillData.mockReturnValue(createSchedulePillData()); + }); + + it("offsets the phone content when schedules make the header widgets visible", () => { + mockUseSchedulePillData.mockReturnValue( + createSchedulePillData({ + hasVisibleSchedules: true, + }), + ); + + render( + + +
Body content
+
+
, + ); + + expect(screen.getByText("Body content").parentElement).toHaveClass("phone:top-[calc(theme(spacing.1)*12+57px)]"); + }); +}); diff --git a/client/src/protoFleet/components/AppLayout/AppLayout.tsx b/client/src/protoFleet/components/AppLayout/AppLayout.tsx new file mode 100644 index 000000000..3b42eddde --- /dev/null +++ b/client/src/protoFleet/components/AppLayout/AppLayout.tsx @@ -0,0 +1,59 @@ +import { ReactNode, useState } from "react"; +import clsx from "clsx"; + +import NavigationMenu from "../NavigationMenu"; +import { ScheduleApiProvider } from "@/protoFleet/api/ScheduleApiProvider"; +import PageHeader from "@/protoFleet/components/PageHeader"; +import { useSchedulePillData } from "@/protoFleet/components/PageHeader/useSchedulePillData"; +import { primaryNavItems } from "@/protoFleet/config/navItems"; +import { usePageBackground } from "@/protoFleet/hooks/usePageBackground"; +import { useReactiveLocalStorage } from "@/shared/hooks/useReactiveLocalStorage"; +import { useWindowDimensions } from "@/shared/hooks/useWindowDimensions"; + +type Props = { + children: ReactNode; +}; + +const AppLayoutContent = ({ children }: Props) => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const { bgClass } = usePageBackground(); + const { isPhone } = useWindowDimensions(); + const [dismissedSetup] = useReactiveLocalStorage("completeSetupDismissed"); + const schedulePillData = useSchedulePillData(); + const hasDismissedSetup = Boolean(dismissedSetup); + + const showPhoneWidgets = isPhone && (hasDismissedSetup || schedulePillData.hasVisibleSchedules); + + return ( +
+
+ setIsMenuOpen(false)} /> +
+ +
+ setIsMenuOpen(true)} schedulePillData={schedulePillData} /> +
+ +
+ {children} +
+
+ ); +}; + +const AppLayout = (props: Props) => ( + + + +); + +export default AppLayout; diff --git a/client/src/protoFleet/components/AppLayout/index.ts b/client/src/protoFleet/components/AppLayout/index.ts new file mode 100644 index 000000000..fa6be263a --- /dev/null +++ b/client/src/protoFleet/components/AppLayout/index.ts @@ -0,0 +1,3 @@ +import AppLayout from "./AppLayout"; + +export default AppLayout; diff --git a/client/src/protoFleet/components/DeviceSetList/DeviceSetList.test.tsx b/client/src/protoFleet/components/DeviceSetList/DeviceSetList.test.tsx new file mode 100644 index 000000000..674270d08 --- /dev/null +++ b/client/src/protoFleet/components/DeviceSetList/DeviceSetList.test.tsx @@ -0,0 +1,210 @@ +import { ReactNode } from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; + +import DeviceSetList from "./DeviceSetList"; +import type { DeviceSetListItem } from "./DeviceSetList"; +import { DeviceSetSchema, DeviceSetStatsSchema } from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; +import type { DeviceSet, DeviceSetStats } from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; +import NoFilterResultsEmptyState from "@/protoFleet/components/NoFilterResultsEmptyState"; + +vi.mock("recharts", () => ({ + ResponsiveContainer: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + LineChart: ({ children }: { children: ReactNode }) =>
{children}
, + ReferenceLine: () =>
, + Line: () =>
, + XAxis: () =>
, + YAxis: () =>
, +})); + +const createMockDeviceSet = (id: bigint, label: string): DeviceSet => + create(DeviceSetSchema, { + id, + label, + deviceCount: 5, + typeDetails: { case: "groupInfo", value: {} }, + }); + +const createMockStats = (deviceSetId: bigint): DeviceSetStats => + create(DeviceSetStatsSchema, { + deviceSetId, + deviceCount: 5, + reportingCount: 5, + totalHashrateThs: 100, + avgEfficiencyJth: 25, + totalPowerKw: 10, + minTemperatureC: 30, + maxTemperatureC: 60, + hashingCount: 4, + brokenCount: 1, + offlineCount: 0, + sleepingCount: 0, + hashrateReportingCount: 5, + efficiencyReportingCount: 5, + powerReportingCount: 5, + temperatureReportingCount: 5, + }); + +const defaultProps = { + renderName: (item: DeviceSetListItem) => {item.deviceSet.label}, + renderMiners: (item: DeviceSetListItem) => {item.deviceSet.deviceCount}, + currentSort: { field: "name" as const, direction: "asc" as const }, + onSort: vi.fn(), + itemName: { singular: "group", plural: "groups" }, +}; + +describe("DeviceSetList", () => { + it("uses descending sort when the issues header is selected", () => { + const deviceSet = createMockDeviceSet(1n, "Group A"); + const stats = createMockStats(1n); + const onSort = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Issues" })); + expect(onSort).toHaveBeenCalledWith("issues", "desc"); + }); + + describe("emptyStateRow prop", () => { + it("renders empty state row when items are empty and emptyStateRow is provided", () => { + render( + No matching items
} + />, + ); + + expect(screen.getByTestId("list-empty-row")).toBeInTheDocument(); + expect(screen.getByText("No matching items")).toBeInTheDocument(); + }); + + it("does not render empty state row when items are present", () => { + const deviceSet = createMockDeviceSet(1n, "Group A"); + const stats = createMockStats(1n); + + render( + No matching items
} + />, + ); + + expect(screen.queryByTestId("list-empty-row")).not.toBeInTheDocument(); + expect(screen.getByText("Group A")).toBeInTheDocument(); + }); + + it("does not render empty state row when items are empty and emptyStateRow is undefined", () => { + render(); + + expect(screen.queryByTestId("list-empty-row")).not.toBeInTheDocument(); + }); + + it("keeps column headers visible when showing empty state row", () => { + render( + No matching items
} + />, + ); + + expect(screen.getByTestId("list-header")).toBeInTheDocument(); + expect(screen.getByText("Name")).toBeInTheDocument(); + }); + }); + + describe("no results empty state content", () => { + const renderEmptyState = (onClearFilters: () => void) => ( + + ); + + it("renders 'No results' heading in the empty state", () => { + const handleClearFilters = vi.fn(); + + render( + , + ); + + expect(screen.getByText("No results")).toBeInTheDocument(); + }); + + it("renders description text in the empty state", () => { + const handleClearFilters = vi.fn(); + + render( + , + ); + + expect(screen.getByText("Try adjusting or clearing your filters.")).toBeInTheDocument(); + }); + + it("renders the 'Clear all filters' button in the empty state", () => { + const handleClearFilters = vi.fn(); + + render( + , + ); + + expect(screen.getByTestId("clear-all-filters-button")).toBeInTheDocument(); + expect(screen.getByText("Clear all filters")).toBeInTheDocument(); + }); + + it("calls the clear filters handler when 'Clear all filters' button is clicked", () => { + const handleClearFilters = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByTestId("clear-all-filters-button")); + expect(handleClearFilters).toHaveBeenCalledTimes(1); + }); + }); + + describe("pagination visibility with empty state", () => { + it("does not render pagination when items are empty and empty state is shown", () => { + render( + No results
} + />, + ); + + expect(screen.queryByLabelText("Previous page")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Next page")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/client/src/protoFleet/components/DeviceSetList/DeviceSetList.tsx b/client/src/protoFleet/components/DeviceSetList/DeviceSetList.tsx new file mode 100644 index 000000000..27d65e360 --- /dev/null +++ b/client/src/protoFleet/components/DeviceSetList/DeviceSetList.tsx @@ -0,0 +1,146 @@ +import { type ReactNode, useCallback, useMemo, useRef } from "react"; + +import { DEFAULT_PAGE_SIZE, deviceSetColTitles, type DeviceSetColumn } from "./constants"; +import { createDeviceSetColConfig } from "./deviceSetColConfig"; +import { getDefaultSortDirection, SORTABLE_COLUMNS } from "./sortConfig"; +import type { DeviceSet, DeviceSetStats } from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; +import { useTemperatureUnit } from "@/protoFleet/store"; +import { ChevronDown } from "@/shared/assets/icons"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import List from "@/shared/components/List"; +import { type SortDirection } from "@/shared/components/List/types"; + +export type DeviceSetListItem = { + id: string; + deviceSet: DeviceSet; + stats?: DeviceSetStats; +}; + +const DEFAULT_ACTIVE_COLS: DeviceSetColumn[] = [ + "name", + "miners", + "issues", + "hashrate", + "efficiency", + "power", + "temperature", + "health", +]; + +type DeviceSetListProps = { + deviceSets: DeviceSet[]; + statsMap: Map; + renderName: (item: DeviceSetListItem) => ReactNode; + renderMiners: (item: DeviceSetListItem) => ReactNode; + currentSort: { field: DeviceSetColumn; direction: SortDirection }; + onSort: (field: DeviceSetColumn, direction: SortDirection) => void; + itemName: { singular: string; plural: string }; + columns?: DeviceSetColumn[]; + loading?: boolean; + total?: number; + pageSize?: number; + currentPage?: number; + hasPreviousPage?: boolean; + hasNextPage?: boolean; + onNextPage?: () => void; + onPrevPage?: () => void; + onRowClick?: (item: DeviceSetListItem, index: number) => void; + emptyStateRow?: ReactNode; +}; + +const DeviceSetList = ({ + deviceSets, + statsMap, + renderName, + renderMiners, + currentSort, + onSort, + itemName, + columns = DEFAULT_ACTIVE_COLS, + loading, + total, + pageSize = DEFAULT_PAGE_SIZE, + currentPage = 0, + hasPreviousPage = false, + hasNextPage = false, + onNextPage, + onPrevPage, + onRowClick, + emptyStateRow, +}: DeviceSetListProps) => { + const topRef = useRef(null); + const temperatureUnit = useTemperatureUnit(); + + const items: DeviceSetListItem[] = useMemo( + () => deviceSets.map((deviceSet) => ({ id: String(deviceSet.id), deviceSet, stats: statsMap.get(deviceSet.id) })), + [deviceSets, statsMap], + ); + + const colConfig = useMemo( + () => createDeviceSetColConfig({ renderName, renderMiners, temperatureUnit }), + [renderName, renderMiners, temperatureUnit], + ); + + const handleNextPage = useCallback(() => { + onNextPage?.(); + topRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [onNextPage]); + + const handlePrevPage = useCallback(() => { + onPrevPage?.(); + topRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [onPrevPage]); + + const firstItemIndex = currentPage * pageSize + 1; + const lastItemIndex = currentPage * pageSize + deviceSets.length; + const shouldRenderPagination = !loading && total !== undefined && total > 0; + + return ( + <> +
+ + activeCols={columns} + colTitles={deviceSetColTitles} + colConfig={colConfig} + items={items} + itemKey="id" + hideTotal + overflowContainer={false} + sortableColumns={SORTABLE_COLUMNS} + currentSort={currentSort} + onSort={onSort} + getDefaultSortDirection={getDefaultSortDirection} + onRowClick={onRowClick} + emptyStateRow={emptyStateRow} + /> + + {shouldRenderPagination && ( +
+ + Showing {firstItemIndex}–{lastItemIndex} of {total} {itemName.plural} + +
+
+
+ )} + + ); +}; + +export default DeviceSetList; diff --git a/client/src/protoFleet/components/DeviceSetList/StatCell.tsx b/client/src/protoFleet/components/DeviceSetList/StatCell.tsx new file mode 100644 index 000000000..1b982486b --- /dev/null +++ b/client/src/protoFleet/components/DeviceSetList/StatCell.tsx @@ -0,0 +1,66 @@ +import { type ReactNode } from "react"; +import { createPortal } from "react-dom"; + +import { Info } from "@/shared/assets/icons"; +import { useFloatingPosition } from "@/shared/hooks/useFloatingPosition"; + +const InfoTooltip = ({ heading, body }: { heading: string; body: string }) => { + const { triggerRef, floatingStyle, isVisible, show, hide } = useFloatingPosition({ + placement: "bottom-end", + gap: 8, + minWidth: 320, + }); + + return ( + <> + + {isVisible && + createPortal( +
+
{heading}
+
{body}
+
, + document.body, + )} + + ); +}; + +const StatCell = ({ + metricReportingCount, + deviceCount, + children, +}: { + metricReportingCount: number; + deviceCount: number; + children: ReactNode; +}) => { + if (metricReportingCount >= deviceCount) return <>{children}; + + return ( +
+ {children} + +
+ ); +}; + +export default StatCell; diff --git a/client/src/protoFleet/components/DeviceSetList/constants.ts b/client/src/protoFleet/components/DeviceSetList/constants.ts new file mode 100644 index 000000000..bca41fb11 --- /dev/null +++ b/client/src/protoFleet/components/DeviceSetList/constants.ts @@ -0,0 +1,29 @@ +import type { ColTitles } from "@/shared/components/List/types"; + +export const deviceSetCols = { + name: "name", + zone: "zone", + miners: "miners", + issues: "issues", + hashrate: "hashrate", + efficiency: "efficiency", + power: "power", + temperature: "temperature", + health: "health", +} as const; + +export type DeviceSetColumn = (typeof deviceSetCols)[keyof typeof deviceSetCols]; + +export const deviceSetColTitles: ColTitles = { + name: "Name", + zone: "Zone", + miners: "Miners", + issues: "Issues", + hashrate: "Total Hashrate", + efficiency: "Avg Efficiency", + power: "Total Power", + temperature: "Temperature", + health: "Health", +}; + +export const DEFAULT_PAGE_SIZE = 50; diff --git a/client/src/protoFleet/components/DeviceSetList/deviceSetColConfig.tsx b/client/src/protoFleet/components/DeviceSetList/deviceSetColConfig.tsx new file mode 100644 index 000000000..11753a724 --- /dev/null +++ b/client/src/protoFleet/components/DeviceSetList/deviceSetColConfig.tsx @@ -0,0 +1,115 @@ +import type { ReactNode } from "react"; + +import { deviceSetCols, type DeviceSetColumn } from "./constants"; +import type { DeviceSetListItem } from "./DeviceSetList"; +import StatCell from "./StatCell"; +import CompositionBar, { type Segment } from "@/shared/components/CompositionBar"; +import { type ColConfig } from "@/shared/components/List/types"; +import type { TemperatureUnit } from "@/shared/features/preferences"; +import { getDisplayValue } from "@/shared/utils/stringUtils"; +import { formatTempRange } from "@/shared/utils/utility"; + +const INACTIVE_PLACEHOLDER = "—"; + +const HEALTH_COLOR_MAP = { + OK: "bg-core-primary-fill", + CRITICAL: "bg-intent-critical-fill", + NA: "bg-core-accent-fill", +}; + +type CreateDeviceSetColConfigParams = { + renderName: (item: DeviceSetListItem) => ReactNode; + renderMiners: (item: DeviceSetListItem) => ReactNode; + temperatureUnit: TemperatureUnit; +}; + +const createDeviceSetColConfig = ({ + renderName, + renderMiners, + temperatureUnit, +}: CreateDeviceSetColConfigParams): ColConfig => ({ + [deviceSetCols.name]: { + component: (item: DeviceSetListItem) => renderName(item), + width: "min-w-44", + }, + [deviceSetCols.zone]: { + component: (item: DeviceSetListItem) => { + if (item.deviceSet.typeDetails.case !== "rackInfo") return {INACTIVE_PLACEHOLDER}; + return {item.deviceSet.typeDetails.value.zone || INACTIVE_PLACEHOLDER}; + }, + width: "min-w-28", + }, + [deviceSetCols.miners]: { + component: (item: DeviceSetListItem) => renderMiners(item), + width: "min-w-20", + }, + [deviceSetCols.issues]: { + component: (item: DeviceSetListItem) => { + if (!item.stats) return {INACTIVE_PLACEHOLDER}; + const count = + item.stats.controlBoardIssueCount + + item.stats.fanIssueCount + + item.stats.hashBoardIssueCount + + item.stats.psuIssueCount; + if (count === 0) return 0; + return {count}; + }, + width: "min-w-20", + }, + [deviceSetCols.hashrate]: { + component: (item: DeviceSetListItem) => { + if (!item.stats || item.stats.hashrateReportingCount === 0) return {INACTIVE_PLACEHOLDER}; + return {getDisplayValue(item.stats.totalHashrateThs)} TH/s; + }, + width: "min-w-28", + }, + [deviceSetCols.efficiency]: { + component: (item: DeviceSetListItem) => { + if (!item.stats || item.stats.efficiencyReportingCount === 0) return {INACTIVE_PLACEHOLDER}; + return ( + + {getDisplayValue(item.stats.avgEfficiencyJth)} J/TH + + ); + }, + width: "min-w-28", + }, + [deviceSetCols.power]: { + component: (item: DeviceSetListItem) => { + if (!item.stats || item.stats.powerReportingCount === 0) return {INACTIVE_PLACEHOLDER}; + return ( + + {getDisplayValue(item.stats.totalPowerKw)} kW + + ); + }, + width: "min-w-24", + }, + [deviceSetCols.temperature]: { + component: (item: DeviceSetListItem) => { + if (!item.stats || item.stats.temperatureReportingCount === 0) return {INACTIVE_PLACEHOLDER}; + return {formatTempRange(item.stats.minTemperatureC, item.stats.maxTemperatureC, temperatureUnit)}; + }, + width: "min-w-28", + }, + [deviceSetCols.health]: { + component: (item: DeviceSetListItem) => { + if (!item.stats || item.stats.deviceCount === 0) return {INACTIVE_PLACEHOLDER}; + const { hashingCount, brokenCount, offlineCount, sleepingCount } = item.stats; + const segments: Segment[] = [ + { name: "Healthy", status: "OK", count: hashingCount }, + { name: "Needs Attention", status: "CRITICAL", count: brokenCount }, + { name: "Offline", status: "NA", count: offlineCount + sleepingCount }, + ]; + + return ( +
+ +
+ ); + }, + width: "min-w-32", + }, +}); + +export { createDeviceSetColConfig }; diff --git a/client/src/protoFleet/components/DeviceSetList/index.ts b/client/src/protoFleet/components/DeviceSetList/index.ts new file mode 100644 index 000000000..acae4c166 --- /dev/null +++ b/client/src/protoFleet/components/DeviceSetList/index.ts @@ -0,0 +1,4 @@ +export { default as DeviceSetList } from "./DeviceSetList"; +export type { DeviceSetListItem } from "./DeviceSetList"; +export { deviceSetCols, type DeviceSetColumn, DEFAULT_PAGE_SIZE } from "./constants"; +export { issueOptions, useIssueFilter } from "./issueFilterConstants"; diff --git a/client/src/protoFleet/components/DeviceSetList/issueFilterConstants.ts b/client/src/protoFleet/components/DeviceSetList/issueFilterConstants.ts new file mode 100644 index 000000000..930317d8a --- /dev/null +++ b/client/src/protoFleet/components/DeviceSetList/issueFilterConstants.ts @@ -0,0 +1,30 @@ +import { useCallback, useRef } from "react"; + +import { ComponentType } from "@/protoFleet/api/generated/errors/v1/errors_pb"; +import { componentIssues } from "@/protoFleet/features/fleetManagement/components/MinerList/constants"; + +export const issueOptions = [ + { id: componentIssues.controlBoard, label: "Control Board" }, + { id: componentIssues.fans, label: "Fan" }, + { id: componentIssues.hashBoards, label: "Hash Board" }, + { id: componentIssues.psu, label: "PSU" }, +]; + +export const ISSUE_TO_COMPONENT_TYPE: Record = { + [componentIssues.controlBoard]: ComponentType.CONTROL_BOARD, + [componentIssues.fans]: ComponentType.FAN, + [componentIssues.hashBoards]: ComponentType.HASH_BOARD, + [componentIssues.psu]: ComponentType.PSU, +}; + +export function useIssueFilter() { + const selectedIssuesRef = useRef([]); + + const getErrorComponentTypes = useCallback((): number[] => { + return selectedIssuesRef.current + .map((issue) => ISSUE_TO_COMPONENT_TYPE[issue]) + .filter((ct): ct is ComponentType => ct !== undefined); + }, []); + + return { selectedIssuesRef, getErrorComponentTypes }; +} diff --git a/client/src/protoFleet/components/DeviceSetList/sortConfig.test.ts b/client/src/protoFleet/components/DeviceSetList/sortConfig.test.ts new file mode 100644 index 000000000..7130ec777 --- /dev/null +++ b/client/src/protoFleet/components/DeviceSetList/sortConfig.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { getNextSortFromSelection } from "./sortConfig"; +import { SORT_ASC, SORT_DESC } from "@/shared/components/List/types"; + +type SortState = Parameters[1]; + +describe("getNextSortFromSelection", () => { + it("uses ascending when selecting miners from the dropdown", () => { + const currentSort: SortState = { + field: "name", + direction: SORT_ASC, + }; + + expect(getNextSortFromSelection(["miners"], currentSort)).toEqual({ + field: "miners", + direction: SORT_ASC, + }); + }); + + it("uses descending when selecting issues from the dropdown", () => { + const currentSort: SortState = { + field: "name", + direction: SORT_ASC, + }; + + expect(getNextSortFromSelection(["issues"], currentSort)).toEqual({ + field: "issues", + direction: SORT_DESC, + }); + }); + + it("toggles the current sort when the selection is invalid", () => { + const currentSort: SortState = { + field: "name", + direction: SORT_ASC, + }; + + expect(getNextSortFromSelection(["not-a-real-column"], currentSort)).toEqual({ + field: "name", + direction: SORT_DESC, + }); + }); +}); diff --git a/client/src/protoFleet/components/DeviceSetList/sortConfig.ts b/client/src/protoFleet/components/DeviceSetList/sortConfig.ts new file mode 100644 index 000000000..de5e53480 --- /dev/null +++ b/client/src/protoFleet/components/DeviceSetList/sortConfig.ts @@ -0,0 +1,85 @@ +import type { DeviceSetColumn } from "./constants"; +import { deviceSetCols } from "./constants"; +import { SORT_ASC, SORT_DESC, type SortDirection } from "@/shared/components/List/types"; + +type DeviceSetSortConfig = { + defaultDirection: SortDirection; +}; + +type DeviceSetSortState = { + field: DeviceSetColumn; + direction: SortDirection; +}; + +type DeviceSetSortOption = { + id: DeviceSetColumn; + label: string; +}; + +// Only fields backed by the list query are sortable. Telemetry-based columns still +// cannot be sorted globally across pages because they are fetched separately. +const SORT_CONFIG: Partial> = { + [deviceSetCols.name]: { + defaultDirection: SORT_ASC, + }, + [deviceSetCols.zone]: { + defaultDirection: SORT_ASC, + }, + [deviceSetCols.miners]: { + defaultDirection: SORT_DESC, + }, + [deviceSetCols.issues]: { + defaultDirection: SORT_DESC, + }, +}; + +export const RACK_SORT_OPTIONS: DeviceSetSortOption[] = [ + { id: deviceSetCols.name, label: "Name" }, + { id: deviceSetCols.zone, label: "Zone" }, + { id: deviceSetCols.miners, label: "Miners" }, + { id: deviceSetCols.issues, label: "Issues" }, +]; + +export const SORTABLE_COLUMNS = new Set(Object.keys(SORT_CONFIG) as DeviceSetColumn[]); + +function toggleSortDirection(direction: SortDirection): SortDirection { + return direction === SORT_ASC ? SORT_DESC : SORT_ASC; +} + +function isSortableColumn(value: string): value is DeviceSetColumn { + return SORTABLE_COLUMNS.has(value as DeviceSetColumn); +} + +function getDropdownSortDirection(column: DeviceSetColumn): SortDirection { + return column === deviceSetCols.issues ? SORT_DESC : SORT_ASC; +} + +function getSelectedSortField(selected: string[], currentField: DeviceSetColumn): DeviceSetColumn { + return ( + selected.find((value): value is DeviceSetColumn => isSortableColumn(value) && value !== currentField) ?? + selected.find(isSortableColumn) ?? + currentField + ); +} + +export function getDefaultSortDirection(column: DeviceSetColumn): SortDirection { + return SORT_CONFIG[column]?.defaultDirection ?? SORT_ASC; +} + +export function getNextSortFromSelection(selected: string[], currentSort: DeviceSetSortState): DeviceSetSortState { + if (selected.length === 0) { + return { + field: currentSort.field, + direction: toggleSortDirection(currentSort.direction), + }; + } + + const field = getSelectedSortField(selected, currentSort.field); + const direction = + field === currentSort.field ? toggleSortDirection(currentSort.direction) : getDropdownSortDirection(field); + + return { + field, + direction, + }; +} diff --git a/client/src/protoFleet/components/FirmwareUpload/FirmwareUploadComponents.tsx b/client/src/protoFleet/components/FirmwareUpload/FirmwareUploadComponents.tsx new file mode 100644 index 000000000..f9eed5f26 --- /dev/null +++ b/client/src/protoFleet/components/FirmwareUpload/FirmwareUploadComponents.tsx @@ -0,0 +1,181 @@ +import type { ChangeEvent, DragEvent } from "react"; +import { useCallback, useRef, useState } from "react"; +import clsx from "clsx"; +import { Checkmark } from "@/shared/assets/icons"; +import { formatFileSize } from "@/shared/components/FileSizeValue"; +import ProgressCircular from "@/shared/components/ProgressCircular/ProgressCircular"; + +const MIME_TYPES_BY_EXT: Record = { + ".tar.gz": ["application/gzip", "application/x-gzip", ".gz"], + ".zip": ["application/zip"], +}; + +function buildAcceptString(extensions: string[]): string { + const parts = new Set(); + for (const ext of extensions) { + parts.add(ext); + for (const mime of MIME_TYPES_BY_EXT[ext] ?? []) parts.add(mime); + } + return [...parts].join(","); +} + +interface FileDropZoneProps { + extensions: string[]; + onFileSelect: (file: File) => void; + disabled?: boolean; +} + +export function FileDropZone({ extensions, onFileSelect, disabled }: FileDropZoneProps) { + const [isDragActive, setIsDragActive] = useState(false); + const fileInputRef = useRef(null); + + const handleDragEnter = useCallback((e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragActive(true); + }, []); + + const handleDragOver = useCallback((e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const handleDragLeave = useCallback((e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragActive(false); + }, []); + + const handleDrop = useCallback( + (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragActive(false); + if (disabled) return; + const droppedFile = e.dataTransfer.files[0]; + if (droppedFile) onFileSelect(droppedFile); + }, + [disabled, onFileSelect], + ); + + const handleClick = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleFileInputChange = useCallback( + (e: ChangeEvent) => { + const selected = e.target.files?.[0]; + if (selected) onFileSelect(selected); + if (fileInputRef.current) fileInputRef.current.value = ""; + }, + [onFileSelect], + ); + + const formattedExtensions = + extensions.length <= 1 + ? extensions.join(", ") + : `${extensions.slice(0, -1).join(", ")}, and ${extensions[extensions.length - 1]}`; + + return ( +
+
+
Drag update files here
+
or
+ +
+
Supported file types: {formattedExtensions}
+ +
+ ); +} + +interface FileProcessingStatusProps { + state: "hashing" | "checking" | "uploading"; + fileName: string; + fileSize: number; + uploadProgress: number; +} + +export function FileProcessingStatus({ state, fileName, fileSize, uploadProgress }: FileProcessingStatusProps) { + return ( +
+ {state === "uploading" ? ( + + ) : ( + + )} +
+
{fileName}
+
+ {state === "hashing" && "Computing checksum..."} + {state === "checking" && "Checking server..."} + {state === "uploading" && `${uploadProgress}% uploaded · ${formatFileSize(fileSize)}`} +
+
+
+ ); +} + +interface FileReadyStatusProps { + fileName: string; + fileSize: number; +} + +export function FileReadyStatus({ fileName, fileSize }: FileReadyStatusProps) { + return ( +
+ +
+
{fileName}
+
{formatFileSize(fileSize)} · Ready
+
+
+ ); +} + +interface FileErrorStatusProps { + message: string; + onRetry: () => void; +} + +export function FileErrorStatus({ message, onRetry }: FileErrorStatusProps) { + return ( +
+
{message}
+ +
+ ); +} diff --git a/client/src/protoFleet/components/FirmwareUpload/index.ts b/client/src/protoFleet/components/FirmwareUpload/index.ts new file mode 100644 index 000000000..7ec85d117 --- /dev/null +++ b/client/src/protoFleet/components/FirmwareUpload/index.ts @@ -0,0 +1,3 @@ +export { useFirmwareUpload } from "./useFirmwareUpload"; +export type { UploadState, UseFirmwareUploadReturn } from "./useFirmwareUpload"; +export { FileDropZone, FileProcessingStatus, FileReadyStatus, FileErrorStatus } from "./FirmwareUploadComponents"; diff --git a/client/src/protoFleet/components/FirmwareUpload/useFirmwareUpload.test.ts b/client/src/protoFleet/components/FirmwareUpload/useFirmwareUpload.test.ts new file mode 100644 index 000000000..edcd8db00 --- /dev/null +++ b/client/src/protoFleet/components/FirmwareUpload/useFirmwareUpload.test.ts @@ -0,0 +1,317 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useFirmwareUpload } from "./useFirmwareUpload"; + +const mockGetConfig = vi.fn(); +const mockCheckFirmwareFile = vi.fn(); +const mockUploadFirmwareFile = vi.fn(); + +vi.mock("@/protoFleet/api/useFirmwareApi", () => ({ + useFirmwareApi: () => ({ + getConfig: mockGetConfig, + checkFirmwareFile: mockCheckFirmwareFile, + uploadFirmwareFile: mockUploadFirmwareFile, + }), + computeSha256: vi.fn().mockResolvedValue("abc123sha256"), + validateFirmwareFile: vi.fn().mockReturnValue(null), +})); + +const defaultConfig = { + allowedExtensions: [".swu", ".tar.gz", ".zip"], + maxFileSizeBytes: 500 * 1024 * 1024, + chunkSizeBytes: 32 * 1024 * 1024, +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockGetConfig.mockResolvedValue(defaultConfig); + mockCheckFirmwareFile.mockResolvedValue({ exists: false }); + mockUploadFirmwareFile.mockResolvedValue("fw-new-id"); +}); + +describe("useFirmwareUpload", () => { + describe("initial state", () => { + it("returns idle state when inactive", () => { + const { result } = renderHook(() => useFirmwareUpload(false)); + + expect(result.current.state).toBe("idle"); + expect(result.current.file).toBeNull(); + expect(result.current.firmwareFileId).toBeNull(); + expect(result.current.uploadProgress).toBe(0); + expect(result.current.errorMessage).toBeNull(); + expect(result.current.serverConfig).toBeNull(); + }); + + it("does not fetch config when inactive", () => { + renderHook(() => useFirmwareUpload(false)); + + expect(mockGetConfig).not.toHaveBeenCalled(); + }); + }); + + describe("config loading", () => { + it("fetches config when active", async () => { + const { result } = renderHook(() => useFirmwareUpload(true)); + + await vi.waitFor(() => { + expect(result.current.serverConfig).toEqual(defaultConfig); + }); + expect(result.current.state).toBe("idle"); + }); + + it("sets error state when config fetch fails", async () => { + mockGetConfig.mockRejectedValue(new Error("Network error")); + + const { result } = renderHook(() => useFirmwareUpload(true)); + + await vi.waitFor(() => { + expect(result.current.state).toBe("error"); + }); + expect(result.current.errorMessage).toBe("Network error"); + expect(result.current.serverConfig).toBeNull(); + }); + + it("retry re-fetches config after failure", async () => { + mockGetConfig.mockRejectedValueOnce(new Error("Network error")); + + const { result } = renderHook(() => useFirmwareUpload(true)); + + await vi.waitFor(() => { + expect(result.current.state).toBe("error"); + }); + + mockGetConfig.mockResolvedValue(defaultConfig); + + act(() => { + result.current.retry(); + }); + + await vi.waitFor(() => { + expect(result.current.serverConfig).toEqual(defaultConfig); + }); + expect(result.current.state).toBe("idle"); + expect(result.current.errorMessage).toBeNull(); + expect(mockGetConfig).toHaveBeenCalledTimes(2); + }); + }); + + describe("processFile", () => { + it("completes upload when file does not exist on server", async () => { + const { result } = renderHook(() => useFirmwareUpload(true)); + + await vi.waitFor(() => { + expect(result.current.serverConfig).not.toBeNull(); + }); + + const file = new File(["data"], "firmware.swu"); + + act(() => { + result.current.processFile(file); + }); + + await vi.waitFor(() => { + expect(result.current.state).toBe("ready"); + }); + expect(result.current.firmwareFileId).toBe("fw-new-id"); + expect(result.current.file).toBe(file); + expect(mockUploadFirmwareFile).toHaveBeenCalled(); + }); + + it("skips upload when file already exists on server (SHA-256 dedup)", async () => { + mockCheckFirmwareFile.mockResolvedValue({ exists: true, firmwareFileId: "fw-existing" }); + + const { result } = renderHook(() => useFirmwareUpload(true)); + + await vi.waitFor(() => { + expect(result.current.serverConfig).not.toBeNull(); + }); + + const file = new File(["data"], "firmware.swu"); + + act(() => { + result.current.processFile(file); + }); + + await vi.waitFor(() => { + expect(result.current.state).toBe("ready"); + }); + expect(result.current.firmwareFileId).toBe("fw-existing"); + expect(mockUploadFirmwareFile).not.toHaveBeenCalled(); + }); + + it("falls back to getConfig when called before config loads", async () => { + mockGetConfig.mockReturnValueOnce(new Promise(() => {})); + mockGetConfig.mockResolvedValueOnce(defaultConfig); + + const { result } = renderHook(() => useFirmwareUpload(true)); + + expect(result.current.serverConfig).toBeNull(); + + act(() => { + result.current.processFile(new File(["data"], "firmware.swu")); + }); + + await vi.waitFor(() => { + expect(result.current.state).toBe("ready"); + }); + expect(result.current.firmwareFileId).toBe("fw-new-id"); + expect(mockGetConfig).toHaveBeenCalledTimes(2); + }); + + it("sets error state when check fails", async () => { + mockCheckFirmwareFile.mockRejectedValue(new Error("Check failed")); + + const { result } = renderHook(() => useFirmwareUpload(true)); + + await vi.waitFor(() => { + expect(result.current.serverConfig).not.toBeNull(); + }); + + act(() => { + result.current.processFile(new File(["data"], "firmware.swu")); + }); + + await vi.waitFor(() => { + expect(result.current.state).toBe("error"); + }); + expect(result.current.errorMessage).toBe("Check failed"); + expect(mockUploadFirmwareFile).not.toHaveBeenCalled(); + }); + + it("aborts previous upload when processFile is called again", async () => { + let resolveFirstUpload: (value: string) => void; + mockUploadFirmwareFile + .mockImplementationOnce(() => new Promise((resolve) => (resolveFirstUpload = resolve))) + .mockResolvedValueOnce("fw-second-id"); + + const { result } = renderHook(() => useFirmwareUpload(true)); + + await vi.waitFor(() => { + expect(result.current.serverConfig).not.toBeNull(); + }); + + act(() => { + result.current.processFile(new File(["data"], "first.swu")); + }); + + await vi.waitFor(() => { + expect(result.current.state).toBe("uploading"); + }); + + act(() => { + result.current.processFile(new File(["data"], "second.swu")); + }); + + await vi.waitFor(() => { + expect(result.current.state).toBe("ready"); + }); + + act(() => { + resolveFirstUpload!("fw-first-id"); + }); + + expect(result.current.firmwareFileId).toBe("fw-second-id"); + expect(result.current.file?.name).toBe("second.swu"); + }); + + it("sets error state when upload fails", async () => { + mockUploadFirmwareFile.mockRejectedValue(new Error("Upload failed")); + + const { result } = renderHook(() => useFirmwareUpload(true)); + + await vi.waitFor(() => { + expect(result.current.serverConfig).not.toBeNull(); + }); + + act(() => { + result.current.processFile(new File(["data"], "firmware.swu")); + }); + + await vi.waitFor(() => { + expect(result.current.state).toBe("error"); + }); + expect(result.current.errorMessage).toBe("Upload failed"); + }); + + it("sets error state on validation failure", async () => { + const { validateFirmwareFile } = await import("@/protoFleet/api/useFirmwareApi"); + vi.mocked(validateFirmwareFile).mockReturnValueOnce("Unsupported file type"); + + const { result } = renderHook(() => useFirmwareUpload(true)); + + await vi.waitFor(() => { + expect(result.current.serverConfig).not.toBeNull(); + }); + + act(() => { + result.current.processFile(new File(["data"], "firmware.bin")); + }); + + await vi.waitFor(() => { + expect(result.current.state).toBe("error"); + }); + expect(result.current.errorMessage).toBe("Unsupported file type"); + }); + }); + + describe("reset", () => { + it("clears all upload state back to idle", async () => { + mockUploadFirmwareFile.mockRejectedValue(new Error("fail")); + + const { result } = renderHook(() => useFirmwareUpload(true)); + + await vi.waitFor(() => { + expect(result.current.serverConfig).not.toBeNull(); + }); + + act(() => { + result.current.processFile(new File(["data"], "firmware.swu")); + }); + + await vi.waitFor(() => { + expect(result.current.state).toBe("error"); + }); + + act(() => { + result.current.reset(); + }); + + expect(result.current.state).toBe("idle"); + expect(result.current.file).toBeNull(); + expect(result.current.firmwareFileId).toBeNull(); + expect(result.current.errorMessage).toBeNull(); + expect(result.current.uploadProgress).toBe(0); + }); + }); + + describe("cleanup", () => { + it("aborts in-flight operations when active flips to false", async () => { + let resolveUpload: (value: string) => void; + mockUploadFirmwareFile.mockImplementation(() => new Promise((resolve) => (resolveUpload = resolve))); + + const { result, rerender } = renderHook(({ active }) => useFirmwareUpload(active), { + initialProps: { active: true }, + }); + + await vi.waitFor(() => { + expect(result.current.serverConfig).not.toBeNull(); + }); + + act(() => { + result.current.processFile(new File(["data"], "firmware.swu")); + }); + + await vi.waitFor(() => { + expect(result.current.state).toBe("uploading"); + }); + + rerender({ active: false }); + + act(() => { + resolveUpload!("fw-id"); + }); + + expect(result.current.state).not.toBe("ready"); + }); + }); +}); diff --git a/client/src/protoFleet/components/FirmwareUpload/useFirmwareUpload.ts b/client/src/protoFleet/components/FirmwareUpload/useFirmwareUpload.ts new file mode 100644 index 000000000..5a33c3b0a --- /dev/null +++ b/client/src/protoFleet/components/FirmwareUpload/useFirmwareUpload.ts @@ -0,0 +1,141 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { FirmwareConfig } from "@/protoFleet/api/useFirmwareApi"; +import { computeSha256, useFirmwareApi, validateFirmwareFile } from "@/protoFleet/api/useFirmwareApi"; + +export type UploadState = "idle" | "hashing" | "checking" | "uploading" | "ready" | "error"; + +export interface UseFirmwareUploadReturn { + state: UploadState; + file: File | null; + firmwareFileId: string | null; + uploadProgress: number; + errorMessage: string | null; + serverConfig: FirmwareConfig | null; + processFile: (file: File) => void; + reset: () => void; + retry: () => void; +} + +export function useFirmwareUpload(active: boolean): UseFirmwareUploadReturn { + const [state, setState] = useState("idle"); + const [file, setFile] = useState(null); + const [firmwareFileId, setFirmwareFileId] = useState(null); + const [uploadProgress, setUploadProgress] = useState(0); + const [errorMessage, setErrorMessage] = useState(null); + const [serverConfig, setServerConfig] = useState(null); + const [retryCount, setRetryCount] = useState(0); + const abortControllerRef = useRef(null); + + const { getConfig, checkFirmwareFile, uploadFirmwareFile } = useFirmwareApi(); + + useEffect(() => { + if (active) { + let cancelled = false; + void getConfig() + .then((config) => { + if (cancelled) return; + setServerConfig(config); + setState((prev) => (prev === "error" ? "idle" : prev)); + setErrorMessage(null); + }) + .catch((err) => { + if (cancelled) return; + setErrorMessage(err instanceof Error ? err.message : "Failed to load firmware configuration."); + setState("error"); + }); + return () => { + cancelled = true; + }; + } + }, [active, getConfig, retryCount]); + + useEffect(() => { + if (!active) { + abortControllerRef.current?.abort(); + abortControllerRef.current = null; + } + return () => { + abortControllerRef.current?.abort(); + abortControllerRef.current = null; + }; + }, [active]); + + const reset = useCallback(() => { + abortControllerRef.current?.abort(); + abortControllerRef.current = null; + setState("idle"); + setFile(null); + setFirmwareFileId(null); + setUploadProgress(0); + setErrorMessage(null); + }, []); + + const retry = useCallback(() => { + reset(); + setRetryCount((c) => c + 1); + }, [reset]); + + const processFile = useCallback( + async (selectedFile: File) => { + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + + try { + const config = serverConfig ?? (await getConfig()); + if (controller.signal.aborted) return; + + const validationError = validateFirmwareFile(selectedFile, config); + if (validationError) { + setErrorMessage(validationError); + setState("error"); + return; + } + + setFile(selectedFile); + setState("hashing"); + const sha256 = await computeSha256(selectedFile); + if (controller.signal.aborted) return; + + setState("checking"); + const { exists, firmwareFileId: existingId } = await checkFirmwareFile(sha256, controller.signal); + if (controller.signal.aborted) return; + + if (exists && existingId) { + setFirmwareFileId(existingId); + setState("ready"); + return; + } + + setState("uploading"); + setUploadProgress(0); + const newId = await uploadFirmwareFile(selectedFile, { + onProgress: setUploadProgress, + signal: controller.signal, + }); + if (controller.signal.aborted) return; + setFirmwareFileId(newId); + setState("ready"); + } catch (err) { + if (controller.signal.aborted) return; + setErrorMessage(err instanceof Error ? err.message : String(err)); + setState("error"); + } + }, + [checkFirmwareFile, uploadFirmwareFile, serverConfig, getConfig], + ); + + const wrappedProcessFile = useCallback((f: File) => void processFile(f), [processFile]); + + return { + state, + file, + firmwareFileId, + uploadProgress, + errorMessage, + serverConfig, + processFile: wrappedProcessFile, + reset, + retry, + }; +} diff --git a/client/src/protoFleet/components/Footer/Footer.tsx b/client/src/protoFleet/components/Footer/Footer.tsx new file mode 100644 index 000000000..7f26dac73 --- /dev/null +++ b/client/src/protoFleet/components/Footer/Footer.tsx @@ -0,0 +1,15 @@ +import BuildVersionInfo from "@/shared/components/BuildVersionInfo"; + +/** + * Footer component for the ProtoFleet application + * Includes version information and potentially other footer content + */ +const Footer = () => { + return ( +
+ +
+ ); +}; + +export default Footer; diff --git a/client/src/protoFleet/components/Footer/index.ts b/client/src/protoFleet/components/Footer/index.ts new file mode 100644 index 000000000..3738288b0 --- /dev/null +++ b/client/src/protoFleet/components/Footer/index.ts @@ -0,0 +1 @@ +export { default } from "./Footer"; diff --git a/client/src/protoFleet/components/FullScreenTwoPaneModal/FullScreenTwoPaneModal.stories.tsx b/client/src/protoFleet/components/FullScreenTwoPaneModal/FullScreenTwoPaneModal.stories.tsx new file mode 100644 index 000000000..aac952f10 --- /dev/null +++ b/client/src/protoFleet/components/FullScreenTwoPaneModal/FullScreenTwoPaneModal.stories.tsx @@ -0,0 +1,184 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; + +import FullScreenTwoPaneModal from "./FullScreenTwoPaneModal"; +import { DismissCircle } from "@/shared/assets/icons"; +import { variants } from "@/shared/components/Button"; +import Callout from "@/shared/components/Callout"; +import ProgressCircular from "@/shared/components/ProgressCircular"; + +const SamplePane = ({ label, className }: { label: string; className?: string }) => ( +
+
{label}
+
+
Sample content for {label.toLowerCase()}
+
+
+
Additional content block
+
+
+); + +const PreviewPane = () => ( +
+
Preview
+
+
Preview content appears here
+
+
+); + +type StoryArgs = { + title: string; + isBusy?: boolean; + hasButtons?: boolean; + maxWidth?: string; + showAbovePanes?: boolean; + showLoadingState?: boolean; +}; + +const FullScreenTwoPaneModalStory = ({ + title, + isBusy, + hasButtons = true, + maxWidth, + showAbovePanes, + showLoadingState, +}: StoryArgs) => { + const [open, setOpen] = useState(true); + + if (!open) { + return ( +
+ +
+ ); + } + + return ( + setOpen(false)} + isBusy={isBusy} + buttons={ + hasButtons + ? [ + { text: "Secondary", variant: variants.secondary, onClick: () => {} }, + { text: "Save", variant: variants.primary, onClick: () => {}, disabled: isBusy }, + ] + : undefined + } + maxWidth={maxWidth} + abovePanes={ + showAbovePanes ? ( +
+ } + title="Something went wrong. Please try again." + dismissible + /> +
+ ) : undefined + } + loadingState={ + showLoadingState ? ( +
+ +
+ ) : undefined + } + primaryPane={} + secondaryPane={} + /> + ); +}; + +const meta = { + title: "Shared/FullScreenTwoPaneModal", + component: FullScreenTwoPaneModalStory, + parameters: { + layout: "fullscreen", + }, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "Full Screen Two Pane Modal", + }, +}; + +const WithOverflowButtonsRender = (args: StoryArgs) => { + const [open, setOpen] = useState(true); + + if (!open) { + return ( +
+ +
+ ); + } + + return ( + setOpen(false)} + buttons={[ + { text: "Delete", variant: variants.secondaryDanger, onClick: () => {} }, + { text: "Edit Settings", variant: variants.secondary, onClick: () => {} }, + { text: "Manage", variant: variants.secondary, onClick: () => {} }, + { text: "Save", variant: variants.primary, onClick: () => {} }, + ]} + primaryPane={} + secondaryPane={} + /> + ); +}; + +export const WithOverflowButtons: Story = { + args: { + title: "Modal with Overflow Menu", + hasButtons: false, + }, + render: WithOverflowButtonsRender, +}; + +export const BusyState: Story = { + args: { + title: "Saving Changes", + isBusy: true, + hasButtons: true, + }, +}; + +export const WithAbovePanesContent: Story = { + args: { + title: "Modal with Error", + showAbovePanes: true, + }, +}; + +export const WithLoadingState: Story = { + args: { + title: "Loading Data", + showLoadingState: true, + }, +}; + +export const WithMaxWidth: Story = { + args: { + title: "Constrained Width Modal", + maxWidth: "1280px", + }, +}; diff --git a/client/src/protoFleet/components/FullScreenTwoPaneModal/FullScreenTwoPaneModal.tsx b/client/src/protoFleet/components/FullScreenTwoPaneModal/FullScreenTwoPaneModal.tsx new file mode 100644 index 000000000..e496107a0 --- /dev/null +++ b/client/src/protoFleet/components/FullScreenTwoPaneModal/FullScreenTwoPaneModal.tsx @@ -0,0 +1,220 @@ +import { type ReactNode, useCallback, useRef, useState } from "react"; +import clsx from "clsx"; + +import { Dismiss, Ellipsis } from "@/shared/assets/icons"; +import { sizes, variants } from "@/shared/components/Button"; +import ButtonGroup, { type ButtonProps, groupVariants } from "@/shared/components/ButtonGroup"; +import Divider from "@/shared/components/Divider"; +import Header from "@/shared/components/Header"; +import PageOverlay from "@/shared/components/PageOverlay"; +import Row from "@/shared/components/Row"; +import { useClickOutside } from "@/shared/hooks/useClickOutside"; +import { useKeyDown } from "@/shared/hooks/useKeyDown"; + +const defaultPaneContainerClassName = + "flex min-h-[calc(100dvh-200px)] w-full flex-1 flex-col laptop:grid laptop:min-h-0 laptop:grid-cols-2 laptop:px-10 desktop:px-10 desktop:grid desktop:min-h-0 desktop:grid-cols-2"; +const defaultPrimaryPaneClassName = + "order-2 flex flex-col phone:pl-6 tablet:pl-6 laptop:order-1 laptop:min-h-0 laptop:overflow-y-auto laptop:pl-1 desktop:order-1 desktop:min-h-0 desktop:overflow-y-auto desktop:pl-1"; +const defaultSecondaryPaneClassName = + "order-1 flex flex-col self-stretch bg-surface-overlay phone:mb-6 phone:max-h-[50vh] phone:overflow-y-auto tablet:mb-6 tablet:max-h-[50vh] tablet:overflow-y-auto laptop:order-2 laptop:min-h-0 laptop:rounded-xl laptop:pl-6 desktop:order-2 desktop:min-h-0 desktop:rounded-xl desktop:pl-6"; + +interface FullScreenTwoPaneModalProps { + open: boolean; + title: string; + onDismiss?: () => void; + isBusy?: boolean; + closeAriaLabel?: string; + buttons?: ButtonProps[]; + primaryPane: ReactNode; + secondaryPane: ReactNode; + abovePanes?: ReactNode; + loadingState?: ReactNode; + maxWidth?: string; + paneContainerClassName?: string; + primaryPaneClassName?: string; + secondaryPaneClassName?: string; + className?: string; + zIndex?: string; +} + +const isDangerVariant = (variant: string) => variant === variants.danger || variant === variants.secondaryDanger; + +const OverflowActionSheet = ({ overflowButtons, onClose }: { overflowButtons: ButtonProps[]; onClose: () => void }) => { + const sheetRef = useRef(null); + useClickOutside({ ref: sheetRef, onClickOutside: onClose }); + useKeyDown({ + key: "Escape", + onKeyDown: () => onClose(), + }); + + const nonDangerItems = overflowButtons.filter((b) => !isDangerVariant(b.variant)); + const dangerItems = overflowButtons.filter((b) => isDangerVariant(b.variant)); + + return ( +
+
+ {nonDangerItems.map((button, index) => ( + { + button.onClick?.(); + onClose(); + } + } + divider={false} + > + {button.text} + + ))} + + {dangerItems.length > 0 && nonDangerItems.length > 0 && } + + {dangerItems.map((button, index) => ( + { + button.onClick?.(); + onClose(); + } + } + divider={false} + > + {button.text} + + ))} +
+
+ ); +}; + +const FullScreenTwoPaneModal = ({ + open, + title, + onDismiss, + isBusy = false, + closeAriaLabel = "Close dialog", + buttons, + primaryPane, + secondaryPane, + abovePanes, + loadingState, + maxWidth = "none", + paneContainerClassName, + primaryPaneClassName, + secondaryPaneClassName, + className, + zIndex, +}: FullScreenTwoPaneModalProps) => { + const [showOverflowSheet, setShowOverflowSheet] = useState(false); + + // Split buttons: primary CTA (last primary-variant button) vs overflow (rest) + let primaryButton: ButtonProps | undefined; + let overflowButtons: ButtonProps[] = []; + + if (buttons && buttons.length > 0) { + if (buttons.length === 1) { + primaryButton = buttons[0]; + } else { + let primaryIndex = -1; + for (let i = buttons.length - 1; i >= 0; i--) { + if (buttons[i].variant === variants.primary) { + primaryIndex = i; + break; + } + } + + if (primaryIndex === -1) { + primaryButton = buttons[buttons.length - 1]; + overflowButtons = buttons.slice(0, -1); + } else { + primaryButton = buttons[primaryIndex]; + overflowButtons = buttons.filter((_, i) => i !== primaryIndex); + } + } + } + + const closeSheet = useCallback(() => setShowOverflowSheet(false), []); + + const mobileButtons: ButtonProps[] = []; + + if (overflowButtons.length > 0) { + mobileButtons.push({ + variant: variants.secondary, + onClick: () => setShowOverflowSheet(true), + prefixIcon: , + testId: "overflow-menu-trigger", + ariaLabel: "More actions", + }); + } + + if (primaryButton) { + mobileButtons.push(primaryButton); + } + + return ( + +
+
+
+
} + iconOnClick={() => { + if (!isBusy) { + onDismiss?.(); + } + }} + iconTextColor={isBusy ? "text-text-primary-30" : "text-text-primary"} + inline + buttonsWrapperClassName="hidden laptop:block desktop:block" + buttons={buttons} + > + {/* Mobile buttons: ellipsis + primary CTA */} +
+ +
+
+
+ + {abovePanes} + + {loadingState ?? ( +
+
+
{primaryPane}
+
{secondaryPane}
+
+
+ )} +
+
+ + {showOverflowSheet && } +
+ ); +}; + +export default FullScreenTwoPaneModal; +export type { FullScreenTwoPaneModalProps }; diff --git a/client/src/protoFleet/components/FullScreenTwoPaneModal/index.ts b/client/src/protoFleet/components/FullScreenTwoPaneModal/index.ts new file mode 100644 index 000000000..74504426f --- /dev/null +++ b/client/src/protoFleet/components/FullScreenTwoPaneModal/index.ts @@ -0,0 +1,5 @@ +import FullScreenTwoPaneModal from "./FullScreenTwoPaneModal"; +import type { FullScreenTwoPaneModalProps } from "./FullScreenTwoPaneModal"; + +export default FullScreenTwoPaneModal; +export type { FullScreenTwoPaneModalProps }; diff --git a/client/src/protoFleet/components/LineChart/LineChart.tsx b/client/src/protoFleet/components/LineChart/LineChart.tsx new file mode 100644 index 000000000..ac1353833 --- /dev/null +++ b/client/src/protoFleet/components/LineChart/LineChart.tsx @@ -0,0 +1,37 @@ +import { useMemo } from "react"; +import { type FleetDuration, getFleetDurationMs } from "@/shared/components/DurationSelector"; +import SharedLineChart, { type LineChartProps as SharedLineChartProps } from "@/shared/components/LineChart"; + +export type LineChartProps = SharedLineChartProps & { + heightClass?: string; + duration?: FleetDuration; +}; + +const LineChart = ({ heightClass = "h-100", duration, ...props }: LineChartProps) => { + const { chartData } = props; + + const xAxisDomainOverride = useMemo((): [number, number] | undefined => { + if (!duration || !chartData?.length) return undefined; + const durationMs = getFleetDurationMs(duration); + const startTime = chartData[0].datetime; + return [startTime, startTime + durationMs]; + }, [duration, chartData]); + + const fleetProps = { + ...props, + xAxisDomainOverride, + connectNulls: true, + yAxisTickYOffset: -8, // Move labels up to position above grid lines + visibleTickIndices: [0, 2, 4], // Show labels on lines 1, 3, and 5 + chartMarginTop: 20, // Add top margin to prevent label cutoff + xAxisLabelCount: 4, // Show 4 timestamp positions (last one will be empty) + }; + + return ( +
+ +
+ ); +}; + +export default LineChart; diff --git a/client/src/protoFleet/components/LineChart/index.ts b/client/src/protoFleet/components/LineChart/index.ts new file mode 100644 index 000000000..52a16111a --- /dev/null +++ b/client/src/protoFleet/components/LineChart/index.ts @@ -0,0 +1 @@ +export { default } from "./LineChart"; diff --git a/client/src/protoFleet/components/MinerSelectionList.tsx b/client/src/protoFleet/components/MinerSelectionList.tsx new file mode 100644 index 000000000..04ea3d685 --- /dev/null +++ b/client/src/protoFleet/components/MinerSelectionList.tsx @@ -0,0 +1,436 @@ +import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; +import { create } from "@bufbuild/protobuf"; + +import { + SortConfigSchema, + SortDirection as SortDirectionProto, + SortField, +} from "@/protoFleet/api/generated/common/v1/sort_pb"; +import type { DeviceSet } from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; +import type { MinerStateSnapshot as ProtoMinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { + type MinerListFilter, + MinerListFilterSchema, + PairingStatus, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { useDeviceSets } from "@/protoFleet/api/useDeviceSets"; +import useFleet from "@/protoFleet/api/useFleet"; +import { INACTIVE_PLACEHOLDER } from "@/protoFleet/features/fleetManagement/components/MinerList/constants"; + +import { ChevronDown } from "@/shared/assets/icons"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import List from "@/shared/components/List"; +import type { ActiveFilters, FilterItem } from "@/shared/components/List/Filters/types"; +import type { ColConfig, ColTitles, SortDirection } from "@/shared/components/List/types"; +import { ModalSelectAllFooter } from "@/shared/components/Modal"; +import ProgressCircular from "@/shared/components/ProgressCircular"; + +// --- Exported types --- + +export type DeviceListItem = { + deviceIdentifier: string; + name: string; + model: string; + ipAddress: string; + rackLabel: string; + groupLabels: string[]; +}; + +export type FilterConfig = { + showTypeFilter?: boolean; + showRackFilter?: boolean; + showGroupFilter?: boolean; +}; + +export interface MinerSelectionListHandle { + getSelection: () => { + selectedItems: string[]; + allSelected: boolean; + totalMiners: number | undefined; + filter: MinerListFilter; + }; +} + +export interface MinerSelectionListProps { + filterConfig?: FilterConfig; + initialSelectedItems?: string[]; + isMembersLoading?: boolean; + isRowDisabled?: (item: DeviceListItem) => boolean; + /** When true, renders radio buttons for single-item selection instead of checkboxes. */ + singleSelect?: boolean; + showSelectAllFooter?: boolean; + onSelectionChange?: (state: { + selectedItems: string[]; + allSelected: boolean; + totalMiners: number | undefined; + }) => void; +} + +// --- Constants --- + +const modalCols = { + name: "name", + type: "type", + rack: "rack", + ipAddress: "ipAddress", + group: "group", +} as const; + +type ModalColumn = (typeof modalCols)[keyof typeof modalCols]; + +const modalColTitles: ColTitles = { + name: "Name", + type: "Model", + rack: "Rack", + ipAddress: "IP address", + group: "Group", +}; + +const activeCols: ModalColumn[] = [ + modalCols.name, + modalCols.type, + modalCols.rack, + modalCols.ipAddress, + modalCols.group, +]; + +const modalColConfig: ColConfig = { + [modalCols.name]: { + component: (device: DeviceListItem) => {device.name || device.deviceIdentifier}, + width: "min-w-28", + }, + [modalCols.type]: { + component: (device: DeviceListItem) => {device.model || INACTIVE_PLACEHOLDER}, + width: "min-w-20", + }, + [modalCols.rack]: { + component: (device: DeviceListItem) => {device.rackLabel || INACTIVE_PLACEHOLDER}, + width: "min-w-28", + }, + [modalCols.ipAddress]: { + component: (device: DeviceListItem) => {device.ipAddress || INACTIVE_PLACEHOLDER}, + width: "min-w-24", + }, + [modalCols.group]: { + component: (device: DeviceListItem) => { + const label = device.groupLabels.length > 0 ? device.groupLabels.join(", ") : INACTIVE_PLACEHOLDER; + return {label}; + }, + width: "min-w-24 max-w-48", + }, +}; + +/** Columns that support server-side sorting, mapped to their proto SortField. */ +const SORT_FIELD_BY_COLUMN: Partial> = { + [modalCols.name]: SortField.NAME, + [modalCols.type]: SortField.MODEL, + [modalCols.ipAddress]: SortField.IP_ADDRESS, +}; + +const ALL_SORTABLE_COLUMNS = new Set(Object.keys(SORT_FIELD_BY_COLUMN) as ModalColumn[]); + +const PAGE_SIZE = 50; + +const toDeviceListItem = (miner: ProtoMinerStateSnapshot): DeviceListItem => ({ + deviceIdentifier: miner.deviceIdentifier, + name: miner.name, + model: miner.model, + ipAddress: miner.ipAddress, + rackLabel: miner.rackLabel, + groupLabels: miner.groupLabels, +}); + +// --- Component --- + +const MinerSelectionList = forwardRef( + ( + { + filterConfig, + initialSelectedItems, + isMembersLoading = false, + isRowDisabled, + singleSelect = false, + showSelectAllFooter = true, + onSelectionChange, + }, + ref, + ) => { + const { showTypeFilter = true, showRackFilter = true, showGroupFilter = true } = filterConfig ?? {}; + + const { listGroups, listRacks } = useDeviceSets(); + const [filter, setFilter] = useState(() => create(MinerListFilterSchema, {})); + const [selectedItems, setSelectedItems] = useState(initialSelectedItems ?? []); + const [allSelected, setAllSelected] = useState(false); + const [availableGroups, setAvailableGroups] = useState([]); + const [availableRacks, setAvailableRacks] = useState([]); + const [hasInitialSynced, setHasInitialSynced] = useState(!initialSelectedItems || initialSelectedItems.length > 0); + const [currentSort, setCurrentSort] = useState<{ field: ModalColumn; direction: SortDirection } | undefined>( + undefined, + ); + + // Build proto SortConfig from the current UI sort state + const sortConfig = useMemo(() => { + if (!currentSort) return undefined; + const protoField = SORT_FIELD_BY_COLUMN[currentSort.field]; + if (!protoField) return undefined; + return create(SortConfigSchema, { + field: protoField, + direction: currentSort.direction === "asc" ? SortDirectionProto.ASC : SortDirectionProto.DESC, + }); + }, [currentSort]); + + const { + minerIds, + miners, + totalMiners, + isLoading, + hasMore, + currentPage, + hasPreviousPage, + goToNextPage, + goToPrevPage, + availableModels, + } = useFleet({ + filter, + sort: sortConfig, + pageSize: PAGE_SIZE, + pairingStatuses: [PairingStatus.PAIRED], + }); + + const currentPageItems = useMemo(() => { + if (!miners) return []; + return minerIds + .map((id) => miners[id]) + .filter((snapshot): snapshot is ProtoMinerStateSnapshot => Boolean(snapshot)) + .map(toDeviceListItem); + }, [minerIds, miners]); + + const handleSort = useCallback((field: ModalColumn, direction: SortDirection) => { + setCurrentSort({ field, direction }); + }, []); + + const scrollRef = useRef(null); + const currentPageItemsRef = useRef(currentPageItems); + useEffect(() => { + currentPageItemsRef.current = currentPageItems; + }, [currentPageItems]); + + const scrollToTop = useCallback(() => { + scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + }, []); + + // Sync initialSelectedItems when they arrive asynchronously (edit mode). + // Uses queueMicrotask to avoid synchronous setState inside effect body. + useEffect(() => { + if (hasInitialSynced) return; + if (initialSelectedItems && initialSelectedItems.length > 0) { + queueMicrotask(() => { + setSelectedItems(initialSelectedItems); + setHasInitialSynced(true); + }); + } + }, [initialSelectedItems, hasInitialSynced]); + + // Notify parent of selection changes + useEffect(() => { + onSelectionChange?.({ selectedItems, allSelected, totalMiners }); + }, [selectedItems, allSelected, totalMiners, onSelectionChange]); + + // Expose selection state to parent via imperative handle + useImperativeHandle( + ref, + () => ({ + getSelection: () => ({ selectedItems, allSelected, totalMiners, filter }), + }), + [selectedItems, allSelected, totalMiners, filter], + ); + + const handleSetSelectedItems = useCallback( + (newSelection: string[]) => { + setAllSelected(false); + if (singleSelect) { + // In single-select mode, just keep the selected item (no off-page merging) + setSelectedItems(newSelection.slice(0, 1)); + } else { + setSelectedItems((prev) => { + const currentPageKeys = new Set(currentPageItemsRef.current.map((d) => d.deviceIdentifier)); + const offPageSelections = prev.filter((id) => !currentPageKeys.has(id)); + return [...offPageSelections, ...newSelection.filter((id) => currentPageKeys.has(id))]; + }); + } + }, + [singleSelect], + ); + + const handleNextPage = useCallback(() => { + scrollToTop(); + goToNextPage(); + }, [scrollToTop, goToNextPage]); + + const handlePrevPage = useCallback(() => { + scrollToTop(); + goToPrevPage(); + }, [scrollToTop, goToPrevPage]); + + // Fetch filter options only for enabled filters + useEffect(() => { + if (showGroupFilter) listGroups({ onSuccess: setAvailableGroups }); + if (showRackFilter) listRacks({ onSuccess: setAvailableRacks }); + }, [showGroupFilter, showRackFilter, listGroups, listRacks]); + + const filters = useMemo((): FilterItem[] => { + const items: FilterItem[] = []; + if (showTypeFilter) { + items.push({ + type: "dropdown", + title: "Model", + value: "type", + options: availableModels.map((model) => ({ id: model, label: model })), + defaultOptionIds: [], + }); + } + if (showRackFilter) { + items.push({ + type: "dropdown", + title: "Rack", + value: "rack", + options: availableRacks.map((rack) => ({ id: String(rack.id), label: rack.label })), + defaultOptionIds: [], + }); + } + if (showGroupFilter) { + items.push({ + type: "dropdown", + title: "Group", + value: "group", + options: availableGroups.map((g) => ({ id: String(g.id), label: g.label })), + defaultOptionIds: [], + }); + } + return items; + }, [showTypeFilter, showRackFilter, showGroupFilter, availableModels, availableRacks, availableGroups]); + + const handleServerFilter = useCallback( + async (activeFilters: ActiveFilters) => { + const minerFilter = create(MinerListFilterSchema, { + errorComponentTypes: [], + }); + + const typeFilters = activeFilters.dropdownFilters.type; + if (typeFilters && typeFilters.length > 0) { + minerFilter.models.push(...typeFilters); + } + + if (showRackFilter) { + const rackFilters = activeFilters.dropdownFilters.rack; + if (rackFilters && rackFilters.length > 0) { + minerFilter.rackIds.push(...rackFilters.map((id) => BigInt(id))); + } + } + + if (showGroupFilter) { + const groupFilters = activeFilters.dropdownFilters.group; + if (groupFilters && groupFilters.length > 0) { + minerFilter.groupIds.push(...groupFilters.map((id) => BigInt(id))); + } + } + + setFilter(minerFilter); + }, + [showRackFilter, showGroupFilter], + ); + + const showSpinner = (isLoading || isMembersLoading) && currentPageItems.length === 0; + + if (showSpinner) { + return ( +
+ +
+ ); + } + + return ( +
+
+ + activeCols={activeCols} + colTitles={modalColTitles} + colConfig={modalColConfig} + filters={filters} + onServerFilter={handleServerFilter} + items={currentPageItems} + itemKey="deviceIdentifier" + itemSelectable + selectionType={singleSelect ? "radio" : "checkbox"} + sortableColumns={ALL_SORTABLE_COLUMNS} + currentSort={currentSort} + onSort={handleSort} + customSelectedItems={selectedItems} + customSetSelectedItems={handleSetSelectedItems} + preserveOffPageSelection + isRowDisabled={isRowDisabled} + total={totalMiners} + hideTotal + itemName={{ singular: "miner", plural: "miners" }} + containerClassName="min-h-0" + overflowContainer + stickyBgColor="bg-surface-elevated-base" + footerContent={ + !isLoading && + totalMiners !== undefined && + totalMiners > 0 && ( +
+ + Showing {currentPage * PAGE_SIZE + 1}–{currentPage * PAGE_SIZE + currentPageItems.length} of{" "} + {totalMiners} miners + +
+
+
+ ) + } + /> +
+ {showSelectAllFooter && totalMiners !== undefined && !singleSelect && ( +
+ { + setAllSelected(true); + const selectableItems = isRowDisabled + ? currentPageItems.filter((d) => !isRowDisabled(d)) + : currentPageItems; + setSelectedItems(selectableItems.map((d) => d.deviceIdentifier)); + }} + onSelectNone={() => { + setAllSelected(false); + setSelectedItems([]); + }} + /> +
+ )} +
+ ); + }, +); + +MinerSelectionList.displayName = "MinerSelectionList"; + +export default MinerSelectionList; diff --git a/client/src/protoFleet/components/MiningPools/MiningPoolsForm.stories.tsx b/client/src/protoFleet/components/MiningPools/MiningPoolsForm.stories.tsx new file mode 100644 index 000000000..9a36b8a41 --- /dev/null +++ b/client/src/protoFleet/components/MiningPools/MiningPoolsForm.stories.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from "react"; +import { action } from "storybook/actions"; +import MiningPoolsFormComponent from "@/protoFleet/components/MiningPools/MiningPoolsForm"; +import { MockedPoolApis } from "@/protoFleet/stories/MockedPoolApis"; + +const withMockedPoolApis = (Story: () => ReactNode) => ( + + + +); + +interface MiningPoolsFormArgs { + buttonLabel: string; +} + +export const MiningPoolsForm = ({ buttonLabel }: MiningPoolsFormArgs) => { + return ( + {}} + /> + ); +}; + +export default { + title: "Proto Fleet/MiningPoolsForm", + decorators: [withMockedPoolApis], + args: { + buttonLabel: "Continue", + }, +}; diff --git a/client/src/protoFleet/components/MiningPools/MiningPoolsForm.test.tsx b/client/src/protoFleet/components/MiningPools/MiningPoolsForm.test.tsx new file mode 100644 index 000000000..88b09af72 --- /dev/null +++ b/client/src/protoFleet/components/MiningPools/MiningPoolsForm.test.tsx @@ -0,0 +1,93 @@ +import { fireEvent, render, waitFor } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import MiningPoolsForm from "."; + +const mocks = vi.hoisted(() => { + return { + pools: [] as { url: string; username: string }[], + }; +}); + +vi.mock("@/protoFleet/api/usePools", () => ({ + default: () => ({ + pools: mocks.pools, + createPool: vi.fn().mockResolvedValue({}), + updatePool: vi.fn().mockResolvedValue({}), + deletePool: vi.fn().mockResolvedValue({}), + }), +})); + +vi.mock("@/protoFleet/api/useOnboardedStatus", () => ({ + useOnboardedStatus: () => ({ + poolConfigured: false, + devicePaired: false, + statusLoaded: true, + refetch: vi.fn(), + }), +})); + +describe("MiningPoolsForm", () => { + const buttonLabel = "Continue"; + + test("renders default and backup pool rows", () => { + const { getByText, getAllByText } = render(); + + expect(getByText("Default pool")).toBeInTheDocument(); + expect(getByText("Backup pool #1")).toBeInTheDocument(); + expect(getByText("Backup pool #2")).toBeInTheDocument(); + expect(getAllByText("Not configured")).toHaveLength(3); + }); + + test("displays warning when default pool is invalid", () => { + const { getByTestId, getByRole } = render(); + + const saveButton = getByRole("button", { name: buttonLabel }); + fireEvent.click(saveButton); + expect(getByTestId("warn-default-pool-callout")).toBeInTheDocument(); + }); + + test("disables save button while loading", async () => { + // ensure we have a valid default pool + mocks.pools = [{ url: "https://example.com", username: "user" }]; + + const { getByRole } = render(); + + await waitFor(() => { + // When pools are initialized, at least one "Not configured" should change + const notConfiguredCount = document.body.textContent?.match(/Not configured/g)?.length || 0; + expect(notConfiguredCount).toBeLessThan(3); + }); + + const saveButton = getByRole("button", { name: buttonLabel }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(saveButton).toBeDisabled(); + }); + }); + + test("calls onSaveDone after successful save", async () => { + const mockOnSaveRequested = vi.fn(); + const mockOnSaveDone = vi.fn(); + + // Ensure we have a valid default pool + mocks.pools = [{ url: "https://example.com", username: "user" }]; + + const { getByRole } = render( + , + ); + + await waitFor(() => { + const notConfiguredCount = document.body.textContent?.match(/Not configured/g)?.length || 0; + expect(notConfiguredCount).toBeLessThan(3); + }); + + const saveButton = getByRole("button", { name: buttonLabel }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockOnSaveRequested).toHaveBeenCalled(); + expect(mockOnSaveDone).toHaveBeenCalled(); + }); + }); +}); diff --git a/client/src/protoFleet/components/MiningPools/MiningPoolsForm.tsx b/client/src/protoFleet/components/MiningPools/MiningPoolsForm.tsx new file mode 100644 index 000000000..f183b0075 --- /dev/null +++ b/client/src/protoFleet/components/MiningPools/MiningPoolsForm.tsx @@ -0,0 +1,236 @@ +import { useCallback, useEffect, useState } from "react"; +import { create } from "@bufbuild/protobuf"; +import { + CreatePoolRequestSchema, + DeletePoolRequestSchema, + UpdatePoolRequestSchema, +} from "@/protoFleet/api/generated/pools/v1/pools_pb"; +import { Pool } from "@/protoFleet/api/generated/pools/v1/pools_pb"; + +import { useOnboardedStatus } from "@/protoFleet/api/useOnboardedStatus"; +import usePools from "@/protoFleet/api/usePools"; +import Button from "@/shared/components/Button"; +import Header from "@/shared/components/Header"; +import PoolModal from "@/shared/components/MiningPools/PoolModal"; +import PoolRow from "@/shared/components/MiningPools/PoolRow"; +import { BackupPoolIndex, PoolIndex, PoolInfo as SharedPoolInfo } from "@/shared/components/MiningPools/types"; +import { getEmptyPoolsInfo, isValidPool } from "@/shared/components/MiningPools/utility"; +import { WarnDefaultPoolCallout } from "@/shared/components/MiningPools/WarnDefaultPoolCallout"; + +type PoolInfo = SharedPoolInfo & { + poolId?: bigint; +}; + +interface MiningPoolsProps { + buttonLabel: string; + onSaveRequested?: () => void; + onSaveDone: () => void; + onSaveFailed?: () => void; +} + +const MiningPoolsForm = ({ buttonLabel, onSaveRequested, onSaveDone, onSaveFailed }: MiningPoolsProps) => { + const { pools: existingPools, createPool, updatePool, deletePool, validatePool, validatePoolPending } = usePools(); + + const { refetch: refetchOnboardingStatus } = useOnboardedStatus(); + + const [loading, setLoading] = useState(false); + const [pools, setPools] = useState(getEmptyPoolsInfo()); + + // Initialize and sync pools from existingPools + useEffect(() => { + if (existingPools.length === 0) { + return; + } + const currentPools = existingPools + .sort((a, b) => Number(a.poolId) - Number(b.poolId)) + .map((pool: Pool) => ({ + ...pool, + name: pool.poolName, + password: "", + // TODO: fix priority assignment + priority: pool.poolId, + })); + const maxExistingPriority = Math.max( + // TODO: fix priority assignment + ...existingPools.map((pool: Pool) => Number(pool.poolId)), + ); + const emptyPools = getEmptyPoolsInfo(maxExistingPriority).slice(existingPools.length); + // eslint-disable-next-line react-hooks/set-state-in-effect + setPools([...currentPools, ...emptyPools]); + }, [existingPools]); + + // 0 is the default pool, 1 and 2 are backup pools + const [currentPoolIndex, setCurrentPoolIndex] = useState(null); + + const [warnDefaultPool, setWarnDefaultPool] = useState(false); + + const handleSaveError = (error: string) => { + // TODO better error handling + console.error("Error saving pool:", error); + }; + + const handleContinue = useCallback(() => { + // check if default pool has been entered + const noValidDefaultPool = !isValidPool(pools[0]); + if (noValidDefaultPool) { + setWarnDefaultPool(true); + return; + } + + onSaveRequested?.(); + setLoading(true); + + const validPools = pools.filter((pool) => isValidPool(pool)); + const apiCalls = validPools.map((pool) => { + if (pool.poolId === undefined) { + // create new pool + const createPoolRequest = create(CreatePoolRequestSchema, { + poolConfig: { + poolName: pool.name || "", + url: pool.url, + username: pool.username, + password: pool.password, + }, + }); + return () => createPool({ createPoolRequest, onError: handleSaveError }); + } else { + // update existing pool + const updatePoolRequest = create(UpdatePoolRequestSchema, { + poolId: BigInt(pool.poolId), + poolName: pool.name || "", + url: pool.url, + username: pool.username, + password: pool.password, + }); + return () => updatePool({ updatePoolRequest, onError: handleSaveError }); + } + }); + + // handle deleted pools + existingPools.forEach((pool) => { + // intentionally convert bigint to number for comparison + const foundPool = validPools.find((p) => p.poolId == pool.poolId); + if (foundPool === undefined) { + // delete pool + const deletePoolRequest = create(DeletePoolRequestSchema, { + poolId: pool.poolId, + }); + apiCalls.push(() => { + return deletePool({ deletePoolRequest, onError: handleSaveError }); + }); + } + }); + + apiCalls[0]().then(() => { + // wait for default pool to be saved before saving backup pools + const promises = apiCalls.slice(1).map((apiCall) => { + return apiCall(); + }); + Promise.all(promises) + .then(async () => { + await refetchOnboardingStatus(); + onSaveDone(); + }) + .catch(() => onSaveFailed?.()) + .finally(() => { + setLoading(false); + }); + }); + }, [ + pools, + onSaveRequested, + existingPools, + createPool, + updatePool, + deletePool, + onSaveDone, + onSaveFailed, + refetchOnboardingStatus, + ]); + + const onChangePools = useCallback((newPools: PoolInfo[]) => { + setPools(newPools); + if (isValidPool(newPools[0])) { + setWarnDefaultPool(false); + } + }, []); + + const savePool = useCallback( + async (pool: PoolInfo, isPasswordSet: boolean) => { + // Only send password if it was set + const passwordToSend = isPasswordSet ? pool.password : ""; + + if (pool.poolId === undefined) { + // create new pool + const createPoolRequest = create(CreatePoolRequestSchema, { + poolConfig: { + poolName: pool.name || "", + url: pool.url, + username: pool.username, + password: passwordToSend, + }, + }); + await createPool({ createPoolRequest, onError: handleSaveError }); + } else { + // update existing pool + const updatePoolRequest = create(UpdatePoolRequestSchema, { + poolId: BigInt(pool.poolId), + poolName: pool.name || "", + url: pool.url, + username: pool.username, + password: passwordToSend, + }); + await updatePool({ updatePoolRequest, onError: handleSaveError }); + } + }, + [createPool, updatePool], + ); + + // TODO support connection test + return ( +
+
+ setWarnDefaultPool(false)} show={warnDefaultPool} /> + +
+ {[...Array(3)].map((_, index) => { + const poolIndex = index as PoolIndex; + return ( + setCurrentPoolIndex(poolIndex)} + testId={`pool-${poolIndex}-add-button`} + /> + ); + })} + + setCurrentPoolIndex(null)} + poolIndex={(currentPoolIndex ?? 0) as BackupPoolIndex} + pools={pools} + isTestingConnection={validatePoolPending} + testConnection={validatePool} + onSave={savePool} + disallowUsernameSeparator + /> +
+ + +
+ ); +}; + +export default MiningPoolsForm; diff --git a/client/src/protoFleet/components/MiningPools/index.ts b/client/src/protoFleet/components/MiningPools/index.ts new file mode 100644 index 000000000..9a1c74d79 --- /dev/null +++ b/client/src/protoFleet/components/MiningPools/index.ts @@ -0,0 +1,3 @@ +import MiningPoolsForm from "./MiningPoolsForm"; + +export default MiningPoolsForm; diff --git a/client/src/protoFleet/components/NavigationMenu/FloatingNavigation.tsx b/client/src/protoFleet/components/NavigationMenu/FloatingNavigation.tsx new file mode 100644 index 000000000..7dbca431c --- /dev/null +++ b/client/src/protoFleet/components/NavigationMenu/FloatingNavigation.tsx @@ -0,0 +1,53 @@ +import { useCallback, useLayoutEffect, useState } from "react"; +import clsx from "clsx"; +import Navigation from "@/protoFleet/components/NavigationMenu/Navigation"; +import { NavItem } from "@/protoFleet/config/navItems"; +import { usePreventScroll } from "@/shared/hooks/usePreventScroll"; + +type FloatingNavigationProps = { + items: NavItem[]; + closeMenu?: () => void; +}; + +const FloatingNavigation = ({ items, closeMenu }: FloatingNavigationProps) => { + const [isVisible, setIsVisible] = useState(true); + const { preventScroll } = usePreventScroll(); + useLayoutEffect(() => { + preventScroll(); + }, [preventScroll]); + + const handleCloseMenu = useCallback(() => { + setIsVisible(false); + setTimeout(() => { + closeMenu?.(); + }, 250); + }, [closeMenu]); + + return ( +
+
+ ); +}; + +export default FloatingNavigation; diff --git a/client/src/protoFleet/components/NavigationMenu/Navigation.tsx b/client/src/protoFleet/components/NavigationMenu/Navigation.tsx new file mode 100644 index 000000000..b8c252e32 --- /dev/null +++ b/client/src/protoFleet/components/NavigationMenu/Navigation.tsx @@ -0,0 +1,255 @@ +import { AnimatePresence, motion } from "motion/react"; +import { createElement, useCallback, useMemo, useState } from "react"; +import { Link, useLocation } from "react-router-dom"; +import clsx from "clsx"; +import { useLogoutAction } from "@/protoFleet/api/useLogout"; +import { NavItem, secondaryNavItems } from "@/protoFleet/config/navItems"; +import { usePageBackground } from "@/protoFleet/hooks/usePageBackground"; +import { useRole } from "@/protoFleet/store"; +import { Logo, LogoAlt } from "@/shared/assets/icons"; +import { ArrowLeftCompact } from "@/shared/assets/icons"; +import MorphingPlusMinus from "@/shared/components/MorphingPlusMinus"; +import useCssVariable from "@/shared/hooks/useCssVariable"; +import { useWindowDimensions } from "@/shared/hooks/useWindowDimensions"; +import { cubicBezierValues } from "@/shared/utils/cssUtils"; +import { stripLeadingSlash } from "@/shared/utils/stringUtils"; + +type NavigationProps = { + items: NavItem[]; + className?: string; + closeMenu?: () => void; +}; + +const Navigation = ({ items, className, closeMenu }: NavigationProps) => { + const { pathname } = useLocation(); + const { isPhone, isTablet } = useWindowDimensions(); + const logout = useLogoutAction(); + const { bg } = usePageBackground(); + const currentRole = useRole(); + const [settingsManuallyToggled, setSettingsManuallyToggled] = useState(false); + const [showSettingsHover, setShowSettingsHover] = useState(false); + + const easeGentle = useCssVariable("--ease-gentle", cubicBezierValues); + + const homeItem = useMemo(() => items.find((item) => item.label === "Home"), [items]); + const settingsItem = useMemo(() => items.find((item) => item.label === "Settings"), [items]); + + // Check if current page is a settings sub-item + const isOnSettingsSubPage = useMemo(() => { + const _pathname = stripLeadingSlash(pathname); + return secondaryNavItems + .filter((nav) => nav.parent === "/settings") + .some((nav) => { + const _navPath = stripLeadingSlash(nav.path); + return _pathname === _navPath || _pathname.startsWith(`${_navPath}/`); + }); + }, [pathname]); + + // Derive expanded state: auto-expand if on settings page OR manually toggled + const isSettingsExpanded = settingsManuallyToggled || isOnSettingsSubPage; + + const handleSettingsHover = useCallback((hover: boolean) => { + setShowSettingsHover(hover); + }, []); + + const isCurrentPath = (path: string) => { + const _pathname = stripLeadingSlash(pathname); + const _path = stripLeadingSlash(path); + return _pathname === _path || _pathname.startsWith(`${_path}/`); + }; + + return ( + + ); +}; + +export default Navigation; diff --git a/client/src/protoFleet/components/NavigationMenu/NavigationMenu.stories.tsx b/client/src/protoFleet/components/NavigationMenu/NavigationMenu.stories.tsx new file mode 100644 index 000000000..2a8a0d67a --- /dev/null +++ b/client/src/protoFleet/components/NavigationMenu/NavigationMenu.stories.tsx @@ -0,0 +1,26 @@ +import { ElementType } from "react"; +import { MemoryRouter } from "react-router-dom"; + +import { action } from "storybook/actions"; +import NavigationMenuComponent from "."; +import { primaryNavItems } from "@/protoFleet/config/navItems"; + +export const NavigationMenu = () => { + return ; +}; + +export default { + title: "Proto Fleet/NavigationMenu", + parameters: { + withRouter: false, + }, + args: {}, + argTypes: {}, + decorators: [ + (Story: ElementType) => ( + + + + ), + ], +}; diff --git a/client/src/protoFleet/components/NavigationMenu/NavigationMenu.test.tsx b/client/src/protoFleet/components/NavigationMenu/NavigationMenu.test.tsx new file mode 100644 index 000000000..4ef1112d0 --- /dev/null +++ b/client/src/protoFleet/components/NavigationMenu/NavigationMenu.test.tsx @@ -0,0 +1,59 @@ +import { MemoryRouter } from "react-router-dom"; +import { render, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import NavigationMenu from "./NavigationMenu"; +import { NavItem } from "@/protoFleet/config/navItems"; + +const { mockUseWindowDimensions } = vi.hoisted(() => ({ + mockUseWindowDimensions: vi.fn(), +})); + +vi.mock("@/shared/hooks/useWindowDimensions", () => ({ + useWindowDimensions: mockUseWindowDimensions, +})); + +describe("Navigation Menu", () => { + const items: NavItem[] = [ + { + path: "/foo", + label: "Foo", + }, + { + path: "/bar", + label: "Bar", + }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + mockUseWindowDimensions.mockReturnValue({ + isPhone: false, + isTablet: false, + }); + }); + + it("should render the correct number nav items", () => { + const { getByTestId } = render( + + + , + ); + + const navMenu = getByTestId("navigation-menu"); + const navItems = navMenu.querySelectorAll("li"); + expect(navItems.length).toBe(2); + }); + + it("should show the correct active nav item", async () => { + const { getByText } = render( + + + , + ); + + const currentItem = getByText("Foo").closest("a"); + await waitFor(() => { + expect(currentItem).toHaveClass("bg-core-primary-5"); + }); + }); +}); diff --git a/client/src/protoFleet/components/NavigationMenu/NavigationMenu.tsx b/client/src/protoFleet/components/NavigationMenu/NavigationMenu.tsx new file mode 100644 index 000000000..393b67f15 --- /dev/null +++ b/client/src/protoFleet/components/NavigationMenu/NavigationMenu.tsx @@ -0,0 +1,25 @@ +import FloatingNavigation from "@/protoFleet/components/NavigationMenu/FloatingNavigation"; +import Navigation from "@/protoFleet/components/NavigationMenu/Navigation"; +import { NavItem } from "@/protoFleet/config/navItems"; +import { useWindowDimensions } from "@/shared/hooks/useWindowDimensions"; + +type NavigationMenuProps = { + items: NavItem[]; + isVisible?: boolean; + closeMenu?: () => void; +}; + +const NavigationMenu = ({ items, isVisible, closeMenu }: NavigationMenuProps) => { + const { isPhone, isTablet } = useWindowDimensions(); + + if (isPhone || isTablet) { + if (isVisible) { + return ; + } + return null; + } + + return ; +}; + +export default NavigationMenu; diff --git a/client/src/protoFleet/components/NavigationMenu/constants.ts b/client/src/protoFleet/components/NavigationMenu/constants.ts new file mode 100644 index 000000000..90cca4d7c --- /dev/null +++ b/client/src/protoFleet/components/NavigationMenu/constants.ts @@ -0,0 +1,28 @@ +import { Fleet, Graph, Home, Logs, Repair, Settings } from "@/shared/assets/icons"; + +export const navigationItems = { + home: { + route: "", + icon: Home, + }, + fleet: { + route: "containers", + icon: Fleet, + }, + profitability: { + route: "profitability", + icon: Graph, + }, + repairs: { + route: "repairs", + icon: Repair, + }, + logs: { + route: "logs", + icon: Logs, + }, + settings: { + route: "settings/settings", + icon: Settings, + }, +} as const; diff --git a/client/src/protoFleet/components/NavigationMenu/index.ts b/client/src/protoFleet/components/NavigationMenu/index.ts new file mode 100644 index 000000000..64d49aed6 --- /dev/null +++ b/client/src/protoFleet/components/NavigationMenu/index.ts @@ -0,0 +1,3 @@ +import NavigationMenu from "./NavigationMenu"; + +export default NavigationMenu; diff --git a/client/src/protoFleet/components/NoFilterResultsEmptyState.tsx b/client/src/protoFleet/components/NoFilterResultsEmptyState.tsx new file mode 100644 index 000000000..d0a84a664 --- /dev/null +++ b/client/src/protoFleet/components/NoFilterResultsEmptyState.tsx @@ -0,0 +1,30 @@ +import Button, { sizes, variants } from "@/shared/components/Button"; + +interface NoFilterResultsEmptyStateProps { + hasActiveFilters?: boolean; + onClearFilters?: () => void; +} + +const NoFilterResultsEmptyState = ({ hasActiveFilters = false, onClearFilters }: NoFilterResultsEmptyStateProps) => ( +
+
No results
+ {hasActiveFilters && ( + <> +

Try adjusting or clearing your filters.

+ {onClearFilters && ( + + )} + + )} +
+); + +export default NoFilterResultsEmptyState; diff --git a/client/src/protoFleet/components/PageHeader/BankBalance/BankBalance.stories.tsx b/client/src/protoFleet/components/PageHeader/BankBalance/BankBalance.stories.tsx new file mode 100644 index 000000000..2312ab219 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/BankBalance/BankBalance.stories.tsx @@ -0,0 +1,21 @@ +import BankBalanceComponent from "./BankBalance"; + +interface BankBalanceArgs { + loading: boolean; + balance: number; +} + +export const BankBalance = ({ loading, balance }: BankBalanceArgs) => { + return ; +}; + +export default { + title: "Proto Fleet/Page Header/Bank Balance", + args: { + loading: false, + balance: 1630, + }, + argTypes: { + balance: { control: { type: "number", min: 0 } }, + }, +}; diff --git a/client/src/protoFleet/components/PageHeader/BankBalance/BankBalance.test.tsx b/client/src/protoFleet/components/PageHeader/BankBalance/BankBalance.test.tsx new file mode 100644 index 000000000..f5657edcf --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/BankBalance/BankBalance.test.tsx @@ -0,0 +1,24 @@ +import { render } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import BankBalance from "./BankBalance"; +import { bitcoinCurrency } from "./constants"; + +describe("Bank Balance", () => { + const bankIconTestId = "bank-account-icon"; + const skeletonTestId = "skeleton-bar"; + + test("renders loading state", () => { + const { getByTestId } = render(); + + expect(getByTestId(bankIconTestId)).toBeDefined(); + expect(getByTestId(skeletonTestId)).toBeDefined(); + }); + + test("renders balance with currency", () => { + const { queryByText, getByTestId, queryByTestId } = render(); + + expect(getByTestId(bankIconTestId)).toBeDefined(); + expect(queryByTestId(skeletonTestId)).toBeNull(); + expect(queryByText(bitcoinCurrency, { exact: false })).toBeInTheDocument(); + }); +}); diff --git a/client/src/protoFleet/components/PageHeader/BankBalance/BankBalance.tsx b/client/src/protoFleet/components/PageHeader/BankBalance/BankBalance.tsx new file mode 100644 index 000000000..9bc1d701a --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/BankBalance/BankBalance.tsx @@ -0,0 +1,29 @@ +import { bitcoinCurrency } from "./constants"; +import { BankAccount } from "@/shared/assets/icons"; +import { iconSizes } from "@/shared/assets/icons/constants"; +import Chip from "@/shared/components/Chip"; +import SkeletonBar from "@/shared/components/SkeletonBar"; + +import { getDisplayValue } from "@/shared/utils/stringUtils"; + +interface BankBalanceProps { + balance?: number; + loading?: boolean; +} + +const BankBalance = ({ balance, loading }: BankBalanceProps) => { + const formattedBalance = () => { + if (balance === undefined || balance === null) return; + + if (balance > 1000) return getDisplayValue(balance / 1000) + "k"; + return getDisplayValue(balance); + }; + + return ( + }> + {loading ? : <>{bitcoinCurrency + formattedBalance()}} + + ); +}; + +export default BankBalance; diff --git a/client/src/protoFleet/components/PageHeader/BankBalance/BankBalanceWrapper.tsx b/client/src/protoFleet/components/PageHeader/BankBalance/BankBalanceWrapper.tsx new file mode 100644 index 000000000..a523b4fa9 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/BankBalance/BankBalanceWrapper.tsx @@ -0,0 +1,11 @@ +import BankBalance from "./BankBalance"; + +const BankBalanceWrapper = () => { + // TODO load balance from API + const balance = 1630; + const loading = false; + + return ; +}; + +export default BankBalanceWrapper; diff --git a/client/src/protoFleet/components/PageHeader/BankBalance/constants.ts b/client/src/protoFleet/components/PageHeader/BankBalance/constants.ts new file mode 100644 index 000000000..52a02c3de --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/BankBalance/constants.ts @@ -0,0 +1 @@ +export const bitcoinCurrency = "₿"; diff --git a/client/src/protoFleet/components/PageHeader/BankBalance/index.ts b/client/src/protoFleet/components/PageHeader/BankBalance/index.ts new file mode 100644 index 000000000..3d85019a5 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/BankBalance/index.ts @@ -0,0 +1,3 @@ +import BankBalanceWrapper from "./BankBalanceWrapper"; + +export default BankBalanceWrapper; diff --git a/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/BitcoinExchangeRate.stories.tsx b/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/BitcoinExchangeRate.stories.tsx new file mode 100644 index 000000000..44ff64c72 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/BitcoinExchangeRate.stories.tsx @@ -0,0 +1,21 @@ +import BitcoinExchangeRateComponent from "./BitcoinExchangeRate"; + +interface BitcoinExchangeRateArgs { + loading: boolean; + exchangeRate: number; +} + +export const BitcoinExchangeRate = ({ loading, exchangeRate }: BitcoinExchangeRateArgs) => { + return ; +}; + +export default { + title: "Proto Fleet/Page Header/Bitcoin Exchange Rate", + args: { + loading: false, + exchangeRate: 89729.88, + }, + argTypes: { + exchangeRate: { control: { type: "number", min: 0 } }, + }, +}; diff --git a/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/BitcoinExchangeRate.test.tsx b/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/BitcoinExchangeRate.test.tsx new file mode 100644 index 000000000..201401e2b --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/BitcoinExchangeRate.test.tsx @@ -0,0 +1,27 @@ +import { render } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import BitcoinExchangeRate from "./BitcoinExchangeRate"; + +describe("Bank Balance", () => { + const bitcoinIconTestId = "bitcoin-icon"; + const skeletonTestId = "skeleton-bar"; + + const usdCurrency = "$"; + + test("renders loading state", () => { + const { getByTestId } = render(); + + expect(getByTestId(bitcoinIconTestId)).toBeDefined(); + expect(getByTestId(skeletonTestId)).toBeDefined(); + }); + + test("renders exchange rate with currency", () => { + const { queryByText, getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId(bitcoinIconTestId)).toBeDefined(); + expect(queryByTestId(skeletonTestId)).toBeNull(); + expect(queryByText(usdCurrency, { exact: false })).toBeInTheDocument(); + }); +}); diff --git a/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/BitcoinExchangeRate.tsx b/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/BitcoinExchangeRate.tsx new file mode 100644 index 000000000..5e4ce9a49 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/BitcoinExchangeRate.tsx @@ -0,0 +1,29 @@ +import { useMemo } from "react"; +import { Bitcoin } from "@/shared/assets/icons"; +import { iconSizes } from "@/shared/assets/icons/constants"; +import Chip from "@/shared/components/Chip"; +import SkeletonBar from "@/shared/components/SkeletonBar"; + +interface BitcoinExchangeRateProps { + exchangeRate?: number; + loading?: boolean; +} + +const BitcoinExchangeRate = ({ exchangeRate, loading }: BitcoinExchangeRateProps) => { + const formattedRate = useMemo(() => { + if (exchangeRate === undefined || exchangeRate === null) return; + + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(exchangeRate); + }, [exchangeRate]); + + return ( + }> + {loading ? : <>{formattedRate}} + + ); +}; + +export default BitcoinExchangeRate; diff --git a/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/BitcoinExchangeRateWrapper.tsx b/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/BitcoinExchangeRateWrapper.tsx new file mode 100644 index 000000000..ec52e78f3 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/BitcoinExchangeRateWrapper.tsx @@ -0,0 +1,10 @@ +import BitcoinExchangeRate from "./BitcoinExchangeRate"; + +const BitcoinExchangeRateWrapper = () => { + const exchangeRate = 89729.88; + const loading = false; + + return ; +}; + +export default BitcoinExchangeRateWrapper; diff --git a/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/index.ts b/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/index.ts new file mode 100644 index 000000000..6f889df49 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/index.ts @@ -0,0 +1,3 @@ +import BitcoinExchangeRateWrapper from "./BitcoinExchangeRateWrapper"; + +export default BitcoinExchangeRateWrapper; diff --git a/client/src/protoFleet/components/PageHeader/LocationSelector/LocationSelector.stories.tsx b/client/src/protoFleet/components/PageHeader/LocationSelector/LocationSelector.stories.tsx new file mode 100644 index 000000000..0c0b93318 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/LocationSelector/LocationSelector.stories.tsx @@ -0,0 +1,18 @@ +import LocationSelectorComponent from "./LocationSelector"; + +interface LocationSelectorArgs { + loading: boolean; + location: string; +} + +export const LocationSelector = ({ loading, location }: LocationSelectorArgs) => { + return ; +}; + +export default { + title: "Proto Fleet/Page Header/Location Selector", + args: { + loading: false, + location: "ProtoFleet test lab", + }, +}; diff --git a/client/src/protoFleet/components/PageHeader/LocationSelector/LocationSelector.test.tsx b/client/src/protoFleet/components/PageHeader/LocationSelector/LocationSelector.test.tsx new file mode 100644 index 000000000..bb6ec91cd --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/LocationSelector/LocationSelector.test.tsx @@ -0,0 +1,22 @@ +import { render } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import LocationSelector from "./LocationSelector"; + +describe("Location Selector", () => { + const skeletonTestId = "skeleton-bar"; + + const locationName = "Test lab"; + + test("renders loading state", () => { + const { getByTestId } = render(); + + expect(getByTestId(skeletonTestId)).toBeDefined(); + }); + + test("renders location name", () => { + const { queryByText, queryByTestId } = render(); + + expect(queryByTestId(skeletonTestId)).toBeNull(); + expect(queryByText(locationName)).toBeInTheDocument(); + }); +}); diff --git a/client/src/protoFleet/components/PageHeader/LocationSelector/LocationSelector.tsx b/client/src/protoFleet/components/PageHeader/LocationSelector/LocationSelector.tsx new file mode 100644 index 000000000..2b7691a7b --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/LocationSelector/LocationSelector.tsx @@ -0,0 +1,13 @@ +import SkeletonBar from "@/shared/components/SkeletonBar"; + +interface LocationSelectorProps { + location?: string; + loading?: boolean; +} + +const LocationSelector = ({ location, loading }: LocationSelectorProps) => { + // TODO implement selector with options + return
{loading ? : location}
; +}; + +export default LocationSelector; diff --git a/client/src/protoFleet/components/PageHeader/LocationSelector/LocationSelectorWrapper.tsx b/client/src/protoFleet/components/PageHeader/LocationSelector/LocationSelectorWrapper.tsx new file mode 100644 index 000000000..897c66177 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/LocationSelector/LocationSelectorWrapper.tsx @@ -0,0 +1,11 @@ +import LocationSelector from "./LocationSelector"; + +const LocationSelectorWrapper = () => { + // TODO load location from API + const location = "Proto Fleet Beta"; + const loading = false; + + return ; +}; + +export default LocationSelectorWrapper; diff --git a/client/src/protoFleet/components/PageHeader/LocationSelector/index.ts b/client/src/protoFleet/components/PageHeader/LocationSelector/index.ts new file mode 100644 index 000000000..c0649225e --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/LocationSelector/index.ts @@ -0,0 +1,3 @@ +import LocationSelectorWrapper from "./LocationSelectorWrapper"; + +export default LocationSelectorWrapper; diff --git a/client/src/protoFleet/components/PageHeader/PageHeader.stories.tsx b/client/src/protoFleet/components/PageHeader/PageHeader.stories.tsx new file mode 100644 index 000000000..046cfaed4 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/PageHeader.stories.tsx @@ -0,0 +1,214 @@ +import { type ReactNode, useState } from "react"; +import { create } from "@bufbuild/protobuf"; +import { action } from "storybook/actions"; + +import SchedulePillComponent from "./SchedulePill"; +import { buildSchedulePopoverSections, selectPillSchedule } from "./schedulePillUtils"; +import type { UseSchedulePillDataResult } from "./useSchedulePillData"; +import PageHeaderComponent from "."; +import { + DayOfWeek, + PowerTargetMode, + RecurrenceFrequency, + ScheduleTargetType, + ScheduleType, +} from "@/protoFleet/api/generated/schedule/v1/schedule_pb"; +import { + ScheduleAction as ProtoScheduleAction, + ScheduleSchema, +} from "@/protoFleet/api/generated/schedule/v1/schedule_pb"; +import type { Schedule as ProtoSchedule } from "@/protoFleet/api/generated/schedule/v1/schedule_pb"; +import type { ScheduleAction, ScheduleListItem, ScheduleStatus } from "@/protoFleet/api/useScheduleApi"; + +const HOUR_IN_MS = 60 * 60 * 1000; +const DAY_IN_MS = 24 * HOUR_IN_MS; +const QUARTER_HOUR_IN_MS = 15 * 60 * 1000; + +const roundDateToQuarterHour = (date: Date) => + new Date(Math.ceil(date.getTime() / QUARTER_HOUR_IN_MS) * QUARTER_HOUR_IN_MS); + +const toTimestamp = (date: Date) => ({ + seconds: BigInt(Math.floor(date.getTime() / 1000)), + nanos: 0, +}); + +const createTargets = (count: number) => + Array.from({ length: count }, (_, index) => ({ + targetType: ScheduleTargetType.MINER, + targetId: `miner-${index + 1}`, + })); + +const createScheduleListItem = ({ + id, + name, + priority, + status, + action, + startTime, + endTime, + nextRunAt, + targetSummary, + powerTargetMode, +}: { + id: string; + name: string; + priority: number; + status: ScheduleStatus; + action: ScheduleAction; + startTime: string; + endTime?: string; + nextRunAt?: Date; + targetSummary: string; + powerTargetMode?: PowerTargetMode; +}): ScheduleListItem => { + const protoAction = + action === "setPowerTarget" + ? ProtoScheduleAction.SET_POWER_TARGET + : action === "sleep" + ? ProtoScheduleAction.SLEEP + : ProtoScheduleAction.REBOOT; + + return { + id, + priority, + name, + targetSummary, + scheduleSummary: "Story schedule", + nextRunSummary: nextRunAt ? `Runs on ${nextRunAt.toLocaleString()}` : null, + action, + status, + createdBy: "Storybook", + rawSchedule: create(ScheduleSchema, { + id: BigInt(id), + name, + action: protoAction, + actionConfig: powerTargetMode + ? { + mode: powerTargetMode, + } + : undefined, + targets: createTargets(3), + nextRunAt: nextRunAt ? toTimestamp(nextRunAt) : undefined, + scheduleType: ScheduleType.RECURRING, + recurrence: { + frequency: RecurrenceFrequency.WEEKLY, + daysOfWeek: [DayOfWeek.SATURDAY, DayOfWeek.SUNDAY], + }, + startDate: "2026-04-07", + startTime, + endTime, + timezone: "UTC", + }) as ProtoSchedule, + }; +}; + +const buildStorySchedules = () => { + const roundedNow = roundDateToQuarterHour(new Date()).getTime(); + return [ + createScheduleListItem({ + id: "1", + name: "Weekday ramp-up", + priority: 1, + status: "running", + action: "setPowerTarget", + startTime: "06:00", + endTime: "22:00", + targetSummary: "Applies to 3 miners", + powerTargetMode: PowerTargetMode.MAX, + }), + createScheduleListItem({ + id: "2", + name: "Night shift", + priority: 2, + status: "active", + action: "sleep", + startTime: "22:00", + nextRunAt: new Date(roundedNow + 9 * HOUR_IN_MS + 30 * 60 * 1000), + targetSummary: "Applies to 3 miners", + }), + createScheduleListItem({ + id: "3", + name: "Weekend reboot", + priority: 3, + status: "paused", + action: "reboot", + startTime: "21:45", + nextRunAt: new Date(roundedNow + 4 * DAY_IN_MS + 8 * HOUR_IN_MS + 15 * 60 * 1000), + targetSummary: "Applies to 3 miners", + }), + ]; +}; + +const getToggledStatus = (status: ScheduleStatus): ScheduleStatus => { + switch (status) { + case "paused": + return "active"; + case "running": + case "active": + default: + return "paused"; + } +}; + +const InteractiveSchedulePillStory = () => { + const [schedules, setSchedules] = useState(() => buildStorySchedules()); + const [pendingScheduleId, setPendingScheduleId] = useState(null); + const sections = buildSchedulePopoverSections(schedules); + const pillSchedule = selectPillSchedule(sections); + + if (!pillSchedule) { + throw new Error("Story data is missing a pill schedule"); + } + + const handleToggleScheduleStatus = async (schedule: ScheduleListItem) => { + const nextStatus = getToggledStatus(schedule.status); + setPendingScheduleId(schedule.id); + action("toggle schedule status")(`${schedule.name}: ${schedule.status} -> ${nextStatus}`); + await new Promise((resolve) => { + window.setTimeout(resolve, 200); + }); + setSchedules((currentSchedules) => + currentSchedules.map((currentSchedule) => + currentSchedule.id === schedule.id ? { ...currentSchedule, status: nextStatus } : currentSchedule, + ), + ); + setPendingScheduleId(null); + }; + + return ( + + ); +}; + +const StoryFrame = ({ children }: { children: ReactNode }) => ( +
{children}
+); + +const emptySchedulePillData: UseSchedulePillDataResult = { + hasVisibleSchedules: false, + pillSchedule: null, + sections: [], + pendingScheduleId: null, + onToggleScheduleStatus: async () => {}, +}; + +export const PageHeader = () => { + return ; +}; + +export const SchedulePill = () => { + return ( + + + + ); +}; + +export default { + title: "Proto Fleet/Page Header", +}; diff --git a/client/src/protoFleet/components/PageHeader/PageHeader.test.tsx b/client/src/protoFleet/components/PageHeader/PageHeader.test.tsx new file mode 100644 index 000000000..9fa7cd721 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/PageHeader.test.tsx @@ -0,0 +1,88 @@ +import { MemoryRouter } from "react-router-dom"; +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import PageHeader from "./PageHeader"; +import type { UseSchedulePillDataResult } from "./useSchedulePillData"; +import type { ScheduleListItem } from "@/protoFleet/api/useScheduleApi"; + +const mockUseWindowDimensions = vi.fn(); +const mockUseReactiveLocalStorage = vi.fn(); + +vi.mock("./LocationSelector", () => ({ + default: () =>
Location selector
, +})); + +vi.mock("./SchedulePill", () => ({ + __esModule: true, + default: ({ pillSchedule }: { pillSchedule: { name: string } }) =>
{pillSchedule.name}
, +})); + +vi.mock("@/shared/hooks/useWindowDimensions", () => ({ + useWindowDimensions: () => mockUseWindowDimensions(), +})); + +vi.mock("@/shared/hooks/useReactiveLocalStorage", () => ({ + useReactiveLocalStorage: () => mockUseReactiveLocalStorage(), +})); + +vi.mock("@/shared/assets/icons", () => ({ + Pause: ({ ariaLabel }: { ariaLabel?: string }) => , +})); +const createPillSchedule = (name: string): ScheduleListItem => + ({ + id: "1", + priority: 1, + name, + targetSummary: "Applies to all miners", + scheduleSummary: "Weekdays · 10:00 PM", + nextRunSummary: "Runs tomorrow at 10:00 PM", + action: "sleep", + status: "active", + createdBy: "Review", + rawSchedule: {}, + }) as ScheduleListItem; + +const createSchedulePillData = (overrides: Partial = {}): UseSchedulePillDataResult => ({ + hasVisibleSchedules: false, + pillSchedule: null, + sections: [], + pendingScheduleId: null, + onToggleScheduleStatus: vi.fn(), + ...overrides, +}); + +describe("PageHeader", () => { + beforeEach(() => { + mockUseWindowDimensions.mockReturnValue({ + isPhone: true, + isTablet: false, + }); + mockUseReactiveLocalStorage.mockReturnValue([false, vi.fn()]); + }); + + it("shows the phone widget row when schedules are available even if setup is not dismissed", () => { + const schedulePillData = createSchedulePillData({ + hasVisibleSchedules: true, + pillSchedule: createPillSchedule("Night reboot"), + }); + + render( + + + , + ); + + expect(screen.getByText("Night reboot")).toBeVisible(); + }); + + it("keeps the phone widget row hidden when neither setup nor schedules need space", () => { + render( + + + , + ); + + expect(screen.queryByText("Continue setup")).not.toBeInTheDocument(); + expect(screen.queryByText("Night reboot")).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/protoFleet/components/PageHeader/PageHeader.tsx b/client/src/protoFleet/components/PageHeader/PageHeader.tsx new file mode 100644 index 000000000..e23f9a0f7 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/PageHeader.tsx @@ -0,0 +1,93 @@ +import clsx from "clsx"; +import LocationSelector from "./LocationSelector"; +import SchedulePill from "./SchedulePill"; +import type { UseSchedulePillDataResult } from "./useSchedulePillData"; +import { usePageBackground } from "@/protoFleet/hooks/usePageBackground"; +import { Pause } from "@/shared/assets/icons"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import { useReactiveLocalStorage } from "@/shared/hooks/useReactiveLocalStorage"; +import { useWindowDimensions } from "@/shared/hooks/useWindowDimensions"; +interface PageHeaderProps { + isMenuOpen?: boolean; + openMenu?: () => void; + schedulePillData: UseSchedulePillDataResult; +} + +const headerWidgetEnabled = true; + +const HeaderWidgets = ({ + className, + dismissedSetup, + onContinueSetup, + schedulePillData, +}: { + className?: string; + dismissedSetup: boolean; + onContinueSetup: () => void; + schedulePillData: UseSchedulePillDataResult; +}) => { + const { pillSchedule, sections, pendingScheduleId, onToggleScheduleStatus } = schedulePillData; + + return ( +
+ {pillSchedule ? ( + + ) : null} + {dismissedSetup ? ( +
+ ); +}; + +const PageHeader = ({ isMenuOpen, openMenu, schedulePillData }: PageHeaderProps) => { + const { isPhone, isTablet } = useWindowDimensions(); + const { bgClass } = usePageBackground(); + const [dismissedSetup, setDismissedSetup] = useReactiveLocalStorage("completeSetupDismissed"); + const hasDismissedSetup = Boolean(dismissedSetup); + + const handleCompleteSetup = () => { + setDismissedSetup(false); + }; + + const headerWidgetsProps = { + dismissedSetup: hasDismissedSetup, + onContinueSetup: handleCompleteSetup, + schedulePillData, + }; + const showPhoneWidgets = isPhone && (hasDismissedSetup || schedulePillData.hasVisibleSchedules); + + return ( + <> +
+
+
+ {(isPhone || isTablet) && ( + + )} + +
+ {!isPhone && headerWidgetEnabled && } +
+
+ {showPhoneWidgets && ( +
+ +
+ )} + + ); +}; + +export default PageHeader; diff --git a/client/src/protoFleet/components/PageHeader/SchedulePill.test.tsx b/client/src/protoFleet/components/PageHeader/SchedulePill.test.tsx new file mode 100644 index 000000000..e6a310869 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/SchedulePill.test.tsx @@ -0,0 +1,210 @@ +import type { ReactNode } from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; + +import SchedulePill from "./SchedulePill"; +import { + buildSchedulePopoverSections, + getSchedulePopoverActionSummary, + getSchedulePopoverTargetSummary, + selectPillSchedule, +} from "./schedulePillUtils"; +import { + PowerTargetMode, + ScheduleAction as ProtoScheduleAction, + ScheduleSchema, +} from "@/protoFleet/api/generated/schedule/v1/schedule_pb"; +import type { Schedule as ProtoSchedule } from "@/protoFleet/api/generated/schedule/v1/schedule_pb"; +import type { ScheduleAction, ScheduleListItem, ScheduleStatus } from "@/protoFleet/api/useScheduleApi"; + +vi.mock("./SchedulePopover", () => ({ + __esModule: true, + default: () =>
Popover content
, +})); + +vi.mock("@/shared/components/Popover", () => ({ + __esModule: true, + default: ({ children }: { children: ReactNode }) =>
{children}
, + PopoverProvider: ({ children }: { children: ReactNode }) => <>{children}, + popoverSizes: { + small: "small", + medium: "medium", + normal: "normal", + }, + useResponsivePopover: () => ({ + triggerRef: { current: null }, + }), +})); + +const createScheduleListItem = ({ + id, + name, + priority, + status, + action = "reboot", + powerTargetMode = PowerTargetMode.DEFAULT, + nextRunAt, +}: { + id: string; + name: string; + priority: number; + status: ScheduleStatus; + action?: ScheduleAction; + powerTargetMode?: PowerTargetMode; + nextRunAt?: Date; +}): ScheduleListItem => { + const protoAction = + action === "setPowerTarget" + ? ProtoScheduleAction.SET_POWER_TARGET + : action === "sleep" + ? ProtoScheduleAction.SLEEP + : ProtoScheduleAction.REBOOT; + + return { + id, + priority, + name, + targetSummary: "Applies to 1 rack", + scheduleSummary: "Weekdays · 10:00 PM", + nextRunSummary: "Runs tomorrow at 10:00 PM", + action, + status, + createdBy: "Negar", + rawSchedule: create(ScheduleSchema, { + id: BigInt(id), + name, + action: protoAction, + actionConfig: { + mode: powerTargetMode, + }, + nextRunAt: nextRunAt + ? { + seconds: BigInt(Math.floor(nextRunAt.getTime() / 1000)), + nanos: 0, + } + : undefined, + startDate: "2026-04-07", + startTime: "22:00", + timezone: "UTC", + }) as ProtoSchedule, + }; +}; + +describe("SchedulePill helpers", () => { + it("groups schedules by header priority and limits the popover to three entries", () => { + const sections = buildSchedulePopoverSections([ + createScheduleListItem({ id: "1", name: "Paused low", priority: 5, status: "paused" }), + createScheduleListItem({ id: "2", name: "Running second", priority: 2, status: "running" }), + createScheduleListItem({ id: "3", name: "Active first", priority: 1, status: "active" }), + createScheduleListItem({ id: "4", name: "Running first", priority: 1, status: "running" }), + createScheduleListItem({ id: "5", name: "Completed", priority: 3, status: "completed" }), + createScheduleListItem({ id: "6", name: "Active second", priority: 4, status: "active" }), + ]); + + expect(sections).toHaveLength(2); + expect(sections[0]?.title).toBe("Active now"); + expect(sections[0]?.schedules.map((schedule) => schedule.name)).toEqual(["Running first", "Running second"]); + expect(sections[1]?.title).toBe("Up next"); + expect(sections[1]?.schedules.map((schedule) => schedule.name)).toEqual(["Active first"]); + }); + + it("selects the pill schedule with running schedules first, then active, then paused", () => { + const runningSections = buildSchedulePopoverSections([ + createScheduleListItem({ id: "1", name: "Paused", priority: 2, status: "paused" }), + createScheduleListItem({ id: "2", name: "Running", priority: 3, status: "running" }), + createScheduleListItem({ id: "3", name: "Active", priority: 1, status: "active" }), + ]); + + const activeSections = buildSchedulePopoverSections([ + createScheduleListItem({ id: "4", name: "Paused", priority: 2, status: "paused" }), + createScheduleListItem({ id: "5", name: "Active", priority: 1, status: "active" }), + ]); + + const pausedSections = buildSchedulePopoverSections([ + createScheduleListItem({ id: "6", name: "Paused only", priority: 1, status: "paused" }), + ]); + + expect(selectPillSchedule(runningSections)?.name).toBe("Running"); + expect(selectPillSchedule(activeSections)?.name).toBe("Active"); + expect(selectPillSchedule(pausedSections)?.name).toBe("Paused only"); + }); + + it("keeps the pill label live while the popover is open", () => { + const runningSchedule = createScheduleListItem({ id: "1", name: "Running first", priority: 1, status: "running" }); + const activeSchedule = createScheduleListItem({ id: "2", name: "Active next", priority: 1, status: "active" }); + + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "View schedule details for Running first" })); + + expect(screen.getByText("Popover content")).toBeInTheDocument(); + expect(screen.getByText("Running first")).toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.getByText("Popover content")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "View schedule details for Active next" })).toBeInTheDocument(); + expect(screen.getByText("Active next")).toBeInTheDocument(); + expect(screen.queryByText("Running first")).not.toBeInTheDocument(); + }); + + it("uses the next scheduled occurrence in paused action summaries", () => { + const nextRunAt = new Date("2026-04-11T22:00:00.000Z"); + const schedule = createScheduleListItem({ + id: "7", + name: "Weekend sleep", + priority: 1, + status: "paused", + action: "sleep", + nextRunAt, + }); + const expectedDateTime = new Intl.DateTimeFormat(undefined, { + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(nextRunAt); + + expect(getSchedulePopoverActionSummary("paused", schedule)).toBe(`Sleep · ${expectedDateTime}`); + }); + + it("keeps the fleet-wide target summary for schedules that apply to all miners", () => { + const schedule = { + ...createScheduleListItem({ + id: "8", + name: "Fleet wide sleep", + priority: 1, + status: "active", + action: "sleep", + }), + targetSummary: "Applies to all miners", + rawSchedule: create(ScheduleSchema, { + id: 8n, + name: "Fleet wide sleep", + action: ProtoScheduleAction.SLEEP, + targets: [], + startDate: "2026-04-07", + startTime: "22:00", + timezone: "UTC", + }) as ProtoSchedule, + }; + + expect(getSchedulePopoverTargetSummary(schedule)).toBe("Applies to all miners"); + }); +}); diff --git a/client/src/protoFleet/components/PageHeader/SchedulePill.tsx b/client/src/protoFleet/components/PageHeader/SchedulePill.tsx new file mode 100644 index 000000000..ad1028499 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/SchedulePill.tsx @@ -0,0 +1,76 @@ +import { useState } from "react"; +import clsx from "clsx"; + +import type { ScheduleListItem } from "@/protoFleet/api/useScheduleApi"; +import type { SchedulePopoverSection } from "@/protoFleet/components/PageHeader/schedulePillUtils"; +import SchedulePopover from "@/protoFleet/components/PageHeader/SchedulePopover"; +import { scheduleStatusDotClassName } from "@/protoFleet/features/settings/components/Schedules/constants"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import Popover, { PopoverProvider, popoverSizes, useResponsivePopover } from "@/shared/components/Popover"; +import { positions } from "@/shared/constants"; + +interface SchedulePillProps { + pillSchedule: ScheduleListItem; + sections: SchedulePopoverSection[]; + pendingScheduleId: string | null; + onToggleScheduleStatus: (schedule: ScheduleListItem) => Promise; +} + +const SchedulePillContent = ({ + pillSchedule, + sections, + pendingScheduleId, + onToggleScheduleStatus, +}: SchedulePillProps) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const { triggerRef } = useResponsivePopover(); + + return ( +
+ + + {isPopoverOpen ? ( + setIsPopoverOpen(false)} + closeIgnoreSelectors={[".schedule-pill-trigger"]} + > + setIsPopoverOpen(false)} + /> + + ) : null} +
+ ); +}; + +const SchedulePill = (props: SchedulePillProps) => ( + + + +); + +export default SchedulePill; diff --git a/client/src/protoFleet/components/PageHeader/SchedulePopover.test.tsx b/client/src/protoFleet/components/PageHeader/SchedulePopover.test.tsx new file mode 100644 index 000000000..b2fcaa896 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/SchedulePopover.test.tsx @@ -0,0 +1,73 @@ +import { MemoryRouter } from "react-router-dom"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; + +import SchedulePopover from "./SchedulePopover"; +import { ScheduleSchema } from "@/protoFleet/api/generated/schedule/v1/schedule_pb"; +import type { ScheduleListItem } from "@/protoFleet/api/useScheduleApi"; + +const createSchedule = (id: string, name: string, status: ScheduleListItem["status"]): ScheduleListItem => ({ + id, + priority: Number(id), + name, + targetSummary: "Applies to 1 miner", + scheduleSummary: "Weekdays · 10:00 PM", + nextRunSummary: "Runs tomorrow at 10:00 PM", + action: "reboot", + status, + createdBy: "Review", + rawSchedule: create(ScheduleSchema, { + id: BigInt(id), + name, + startDate: "2026-04-07", + startTime: "22:00", + timezone: "UTC", + }), +}); + +describe("SchedulePopover", () => { + it("disables every toggle button while a schedule update is in flight", () => { + render( + + + , + ); + + expect(screen.getByRole("button", { name: "Pause" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Resume" })).toBeDisabled(); + }); + + it("uses the standard hover background treatment for the schedules link", () => { + render( + + + , + ); + + expect(screen.getByRole("link", { name: "View all schedules" })).toHaveClass( + "hover:bg-core-primary-5", + "px-3", + "py-2.5", + ); + }); +}); diff --git a/client/src/protoFleet/components/PageHeader/SchedulePopover.tsx b/client/src/protoFleet/components/PageHeader/SchedulePopover.tsx new file mode 100644 index 000000000..5ed053e3e --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/SchedulePopover.tsx @@ -0,0 +1,93 @@ +import { Link } from "react-router-dom"; +import clsx from "clsx"; + +import type { ScheduleListItem } from "@/protoFleet/api/useScheduleApi"; +import { + formatSchedulePopoverRelativeStart, + getSchedulePopoverActionSummary, + getSchedulePopoverPowerTargetDetail, + getSchedulePopoverTargetSummary, + type SchedulePopoverSection, +} from "@/protoFleet/components/PageHeader/schedulePillUtils"; +import Button, { sizes, variants } from "@/shared/components/Button"; + +interface SchedulePopoverProps { + sections: SchedulePopoverSection[]; + pendingScheduleId: string | null; + onToggleScheduleStatus: (schedule: ScheduleListItem) => Promise; + onNavigateToSchedules: () => void; +} + +const SchedulePopover = ({ + sections, + pendingScheduleId, + onToggleScheduleStatus, + onNavigateToSchedules, +}: SchedulePopoverProps) => { + return ( +
+
+ {sections.map((section, sectionIndex) => ( +
0, + "pb-3": sectionIndex < sections.length - 1, + })} + > +
{section.title}
+ + {section.schedules.map((schedule) => { + const powerTargetDetail = getSchedulePopoverPowerTargetDetail(schedule); + const relativeStart = section.id === "active" ? formatSchedulePopoverRelativeStart(schedule) : null; + + return ( +
+
+
{schedule.name}
+ {relativeStart ? ( +
{relativeStart}
+ ) : null} +
+ {getSchedulePopoverActionSummary(section.id, schedule)} +
+
+ {getSchedulePopoverTargetSummary(schedule)} +
+ {powerTargetDetail ? ( +
{powerTargetDetail}
+ ) : null} +
+ +
+ ); + })} +
+ ))} +
+ +
+ + View all schedules + +
+
+ ); +}; + +export default SchedulePopover; diff --git a/client/src/protoFleet/components/PageHeader/index.ts b/client/src/protoFleet/components/PageHeader/index.ts new file mode 100644 index 000000000..b294687a4 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/index.ts @@ -0,0 +1,3 @@ +import PageHeader from "./PageHeader"; + +export default PageHeader; diff --git a/client/src/protoFleet/components/PageHeader/schedulePillUtils.ts b/client/src/protoFleet/components/PageHeader/schedulePillUtils.ts new file mode 100644 index 000000000..4143afc00 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/schedulePillUtils.ts @@ -0,0 +1,205 @@ +import { PowerTargetMode, ScheduleType } from "@/protoFleet/api/generated/schedule/v1/schedule_pb"; +import type { ScheduleListItem } from "@/protoFleet/api/useScheduleApi"; +import { scheduleActionLabels } from "@/protoFleet/features/settings/components/Schedules/constants"; +import { + addDaysToDateValue, + buildDateInTimeZone, + formatTimeZoneDateParts, + getTimeZoneDateTimeParts, +} from "@/protoFleet/features/settings/utils/scheduleDateUtils"; + +const MINUTE_IN_MS = 60_000; +const HOUR_IN_MINUTES = 60; +const DAY_IN_MINUTES = 24 * HOUR_IN_MINUTES; +const timeFormatter = new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", +}); +const dateTimeFormatter = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", +}); +const nextRunDateTimeFormatter = new Intl.DateTimeFormat(undefined, { + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", +}); + +const schedulePopoverSectionConfigs = [ + { + id: "running", + title: "Active now", + status: "running", + }, + { + id: "active", + title: "Up next", + status: "active", + }, + { + id: "paused", + title: "Paused", + status: "paused", + }, +] as const satisfies readonly { + id: string; + title: string; + status: ScheduleListItem["status"]; +}[]; + +export type SchedulePopoverSectionId = (typeof schedulePopoverSectionConfigs)[number]["id"]; + +export interface SchedulePopoverSection { + id: SchedulePopoverSectionId; + title: string; + schedules: ScheduleListItem[]; +} + +const MAX_POPOVER_SCHEDULES = 3; + +const sortByPriority = (schedules: ScheduleListItem[]) => + [...schedules].sort((left, right) => left.priority - right.priority); + +const toDate = (seconds: bigint, nanos = 0) => new Date(Number(seconds) * 1000 + Math.floor(nanos / 1_000_000)); + +const shouldUseNextRunDate = (sectionId: SchedulePopoverSectionId) => sectionId === "active" || sectionId === "paused"; + +const formatActionTimeWindow = (schedule: ScheduleListItem, dateValue: string, startSummary: string) => { + if (schedule.action !== "setPowerTarget" || !schedule.rawSchedule.endTime) { + return null; + } + + const endDateValue = + schedule.rawSchedule.endTime < schedule.rawSchedule.startTime ? addDaysToDateValue(dateValue, 1) : dateValue; + const end = buildDateInTimeZone(endDateValue, schedule.rawSchedule.endTime, schedule.rawSchedule.timezone); + + if (!end) { + return null; + } + + return `${scheduleActionLabels[schedule.action]} · ${startSummary} – ${timeFormatter.format(end)}`; +}; + +export const formatSchedulePopoverRelativeStart = (schedule: ScheduleListItem) => { + const nextRunAt = schedule.rawSchedule.nextRunAt; + + if (!nextRunAt) { + return schedule.nextRunSummary ?? "Starting soon"; + } + + const nextRun = toDate(nextRunAt.seconds, nextRunAt.nanos); + const diffMinutes = Math.max(0, Math.floor((nextRun.getTime() - Date.now()) / MINUTE_IN_MS)); + + if (diffMinutes <= 0) { + return "Starting soon"; + } + + const days = Math.floor(diffMinutes / DAY_IN_MINUTES); + const hours = Math.floor((diffMinutes % DAY_IN_MINUTES) / HOUR_IN_MINUTES); + const minutes = diffMinutes % HOUR_IN_MINUTES; + const parts: string[] = []; + + if (days > 0) { + parts.push(`${days}d`); + } + + if (hours > 0 && parts.length < 2) { + parts.push(`${hours}h`); + } + + if (minutes > 0 && parts.length < 2) { + parts.push(`${minutes}m`); + } + + return `Starting in ${parts.join(" ")}`; +}; + +export const getSchedulePopoverActionSummary = (sectionId: SchedulePopoverSectionId, schedule: ScheduleListItem) => { + const { rawSchedule } = schedule; + const referenceDateValue = rawSchedule.startDate; + const start = buildDateInTimeZone(referenceDateValue, rawSchedule.startTime, rawSchedule.timezone); + + if (!start) { + return scheduleActionLabels[schedule.action]; + } + + if (shouldUseNextRunDate(sectionId) && rawSchedule.nextRunAt) { + const nextRun = toDate(rawSchedule.nextRunAt.seconds, rawSchedule.nextRunAt.nanos); + const nextRunParts = getTimeZoneDateTimeParts(nextRun, rawSchedule.timezone); + + if (nextRunParts) { + const nextRunDateValue = formatTimeZoneDateParts(nextRunParts); + const timeWindowSummary = formatActionTimeWindow( + schedule, + nextRunDateValue, + nextRunDateTimeFormatter.format(nextRun), + ); + + if (timeWindowSummary) { + return timeWindowSummary; + } + } + + return `${scheduleActionLabels[schedule.action]} · ${nextRunDateTimeFormatter.format(nextRun)}`; + } + + if (rawSchedule.scheduleType === ScheduleType.ONE_TIME) { + return `${scheduleActionLabels[schedule.action]} · ${dateTimeFormatter.format(start)}`; + } + + return ( + formatActionTimeWindow(schedule, referenceDateValue, timeFormatter.format(start)) ?? + `${scheduleActionLabels[schedule.action]} · ${timeFormatter.format(start)}` + ); +}; + +export const getSchedulePopoverPowerTargetDetail = (schedule: ScheduleListItem) => { + if (schedule.action !== "setPowerTarget") { + return null; + } + + switch (schedule.rawSchedule.actionConfig?.mode) { + case PowerTargetMode.MAX: + return "Max"; + case PowerTargetMode.DEFAULT: + return "Default"; + default: + return null; + } +}; + +export const getSchedulePopoverTargetSummary = (schedule: ScheduleListItem) => schedule.targetSummary; + +export const buildSchedulePopoverSections = (schedules: ScheduleListItem[]): SchedulePopoverSection[] => { + let remainingSlots = MAX_POPOVER_SCHEDULES; + + return schedulePopoverSectionConfigs.reduce((result, sectionConfig) => { + if (remainingSlots <= 0) { + return result; + } + + const sectionSchedules = sortByPriority(schedules.filter((schedule) => schedule.status === sectionConfig.status)); + + if (sectionSchedules.length === 0) { + return result; + } + + const visibleSchedules = sectionSchedules.slice(0, remainingSlots); + remainingSlots -= visibleSchedules.length; + + result.push({ + id: sectionConfig.id, + title: sectionConfig.title, + schedules: visibleSchedules, + }); + + return result; + }, []); +}; + +export const selectPillSchedule = (sections: SchedulePopoverSection[]) => sections[0]?.schedules[0] ?? null; diff --git a/client/src/protoFleet/components/PageHeader/useSchedulePillData.test.tsx b/client/src/protoFleet/components/PageHeader/useSchedulePillData.test.tsx new file mode 100644 index 000000000..8cf99d352 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/useSchedulePillData.test.tsx @@ -0,0 +1,68 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useSchedulePillData } from "./useSchedulePillData"; +import { useScheduleApiContext } from "@/protoFleet/api/ScheduleApiContext"; +import { SCHEDULES_CHANGED_EVENT } from "@/protoFleet/api/scheduleEvents"; + +vi.mock("@/protoFleet/api/ScheduleApiContext", () => ({ + useScheduleApiContext: vi.fn(), +})); + +vi.mock("@/shared/features/toaster", () => ({ + pushToast: vi.fn(), + STATUSES: { + error: "error", + }, +})); + +describe("useSchedulePillData", () => { + const refreshSchedules = vi.fn().mockResolvedValue([]); + + beforeEach(() => { + vi.useFakeTimers(); + refreshSchedules.mockClear(); + vi.mocked(useScheduleApiContext).mockReturnValue({ + schedules: [], + isLoading: false, + listSchedules: vi.fn().mockResolvedValue([]), + refreshSchedules, + createSchedule: vi.fn().mockResolvedValue(undefined), + updateSchedule: vi.fn().mockResolvedValue(undefined), + pauseSchedule: vi.fn(), + resumeSchedule: vi.fn(), + deleteSchedule: vi.fn().mockResolvedValue(undefined), + reorderSchedules: vi.fn().mockResolvedValue(undefined), + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("refreshes immediately and on the polling interval", async () => { + renderHook(() => useSchedulePillData()); + + expect(refreshSchedules).toHaveBeenCalledTimes(1); + expect(refreshSchedules).toHaveBeenNthCalledWith(1, { background: true }); + + await act(async () => { + vi.advanceTimersByTime(30_000); + }); + + expect(refreshSchedules).toHaveBeenCalledTimes(2); + expect(refreshSchedules).toHaveBeenNthCalledWith(2, { background: true }); + }); + + it("does not refetch immediately for same-tab schedule mutation events", async () => { + renderHook(() => useSchedulePillData()); + + refreshSchedules.mockClear(); + + await act(async () => { + window.dispatchEvent(new CustomEvent(SCHEDULES_CHANGED_EVENT)); + }); + + expect(refreshSchedules).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/protoFleet/components/PageHeader/useSchedulePillData.ts b/client/src/protoFleet/components/PageHeader/useSchedulePillData.ts new file mode 100644 index 000000000..a3ab939d8 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/useSchedulePillData.ts @@ -0,0 +1,81 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { buildSchedulePopoverSections, type SchedulePopoverSection, selectPillSchedule } from "./schedulePillUtils"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { useScheduleApiContext } from "@/protoFleet/api/ScheduleApiContext"; +import type { ScheduleListItem } from "@/protoFleet/api/useScheduleApi"; +import { pushToast, STATUSES } from "@/shared/features/toaster"; + +export interface UseSchedulePillDataResult { + hasVisibleSchedules: boolean; + pillSchedule: ScheduleListItem | null; + sections: SchedulePopoverSection[]; + pendingScheduleId: string | null; + onToggleScheduleStatus: (schedule: ScheduleListItem) => Promise; +} + +const POLL_INTERVAL_MS = 30_000; + +export const useSchedulePillData = (): UseSchedulePillDataResult => { + const { schedules, refreshSchedules, pauseSchedule, resumeSchedule } = useScheduleApiContext(); + const [pendingScheduleId, setPendingScheduleId] = useState(null); + + useEffect(() => { + const refreshScheduleSummary = () => { + void refreshSchedules({ background: true }).catch(() => {}); + }; + + refreshScheduleSummary(); + const intervalId = window.setInterval(refreshScheduleSummary, POLL_INTERVAL_MS); + + return () => { + window.clearInterval(intervalId); + }; + }, [refreshSchedules]); + + const { sections, pillSchedule } = useMemo(() => { + const nextSections = buildSchedulePopoverSections(schedules); + + return { + sections: nextSections, + pillSchedule: selectPillSchedule(nextSections), + }; + }, [schedules]); + + const onToggleScheduleStatus = useCallback( + async (schedule: ScheduleListItem) => { + if (schedule.status === "completed") { + return; + } + + setPendingScheduleId(schedule.id); + + try { + if (schedule.status === "paused") { + await resumeSchedule(schedule.id); + } else { + await pauseSchedule(schedule.id); + } + } catch (error) { + pushToast({ + message: getErrorMessage(error, "Failed to update schedule"), + status: STATUSES.error, + }); + } finally { + setPendingScheduleId((current) => (current === schedule.id ? null : current)); + } + }, + [pauseSchedule, resumeSchedule], + ); + + return useMemo( + () => ({ + hasVisibleSchedules: pillSchedule !== null, + pillSchedule, + sections, + pendingScheduleId, + onToggleScheduleStatus, + }), + [onToggleScheduleStatus, pendingScheduleId, pillSchedule, sections], + ); +}; diff --git a/client/src/protoFleet/components/SecondaryNavigation/SecondaryNavigation.stories.tsx b/client/src/protoFleet/components/SecondaryNavigation/SecondaryNavigation.stories.tsx new file mode 100644 index 000000000..6fb8cfe19 --- /dev/null +++ b/client/src/protoFleet/components/SecondaryNavigation/SecondaryNavigation.stories.tsx @@ -0,0 +1,25 @@ +import { ElementType } from "react"; +import { MemoryRouter } from "react-router-dom"; + +import { default as StoryComponent } from "."; +import { secondaryNavItems } from "@/protoFleet/config/navItems"; + +export const SecondaryNavigation = () => { + return ; +}; + +export default { + title: "Proto Fleet/SecondaryNavigation", + parameters: { + withRouter: false, + }, + args: {}, + argTypes: {}, + decorators: [ + (Story: ElementType) => ( + + + + ), + ], +}; diff --git a/client/src/protoFleet/components/SecondaryNavigation/SecondaryNavigation.test.tsx b/client/src/protoFleet/components/SecondaryNavigation/SecondaryNavigation.test.tsx new file mode 100644 index 000000000..723647243 --- /dev/null +++ b/client/src/protoFleet/components/SecondaryNavigation/SecondaryNavigation.test.tsx @@ -0,0 +1,50 @@ +import { MemoryRouter } from "react-router-dom"; +import { render, waitFor } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import SecondaryNavigation from "./SecondaryNavigation"; +import { SecondaryNavItem } from "@/protoFleet/config/navItems"; + +describe("Secondary Navigation", () => { + const items: SecondaryNavItem[] = [ + { + path: "/bar/foo", + label: "Bar Foo", + parent: "/bar", + }, + { + path: "/bar/bar", + label: "Bar Bar", + parent: "/bar", + }, + { + path: "/bar/baz", + label: "Bar Baz", + parent: "/bar", + }, + ]; + + it("should render the correct number nav items", () => { + const { getByTestId } = render( + + + , + ); + + const navMenu = getByTestId("secondary-nav"); + const navItems = navMenu.querySelectorAll("li"); + expect(navItems.length).toBe(3); + }); + + it("should show the correct active nav item", async () => { + const { getByText } = render( + + + , + ); + + const currentItem = getByText("Bar Foo"); + await waitFor(() => { + expect(currentItem).toHaveClass("bg-core-primary-5"); + }); + }); +}); diff --git a/client/src/protoFleet/components/SecondaryNavigation/SecondaryNavigation.tsx b/client/src/protoFleet/components/SecondaryNavigation/SecondaryNavigation.tsx new file mode 100644 index 000000000..06a84631a --- /dev/null +++ b/client/src/protoFleet/components/SecondaryNavigation/SecondaryNavigation.tsx @@ -0,0 +1,66 @@ +import { Link, useLocation } from "react-router-dom"; +import { clsx } from "clsx"; + +import { type SecondaryNavItem } from "@/protoFleet/config/navItems"; +import { useRole } from "@/protoFleet/store"; +import { useWindowDimensions } from "@/shared/hooks/useWindowDimensions"; +import { stripLeadingSlash } from "@/shared/utils/stringUtils"; + +type SecondaryNavigationProps = { + items: SecondaryNavItem[]; +}; + +const SecondaryNavigation = ({ items }: SecondaryNavigationProps) => { + const { pathname } = useLocation(); + const { isPhone, isTablet } = useWindowDimensions(); + const currentRole = useRole(); + + // Hide on mobile and tablet since secondary nav items are shown in main menu + if (isPhone || isTablet) return null; + + // Filter items to only show those whose parent matches the current path and whose role matches + const visibleItems = items.filter((item) => { + const _pathname = stripLeadingSlash(pathname); + const _parent = stripLeadingSlash(item.parent); + const pathMatch = _pathname === _parent || _pathname.startsWith(`${_parent}/`); + const roleMatch = !item.allowedRoles || item.allowedRoles.includes(currentRole); + return pathMatch && roleMatch; + }); + + const isCurrentPath = (path: string) => { + const _pathname = stripLeadingSlash(pathname); + const _path = stripLeadingSlash(path); + return _pathname === _path || _pathname.startsWith(`${_path}/`); + }; + + // if current route has no secondary nav items + // dont render anything + if (visibleItems.length === 0) return null; + + return ( + + ); +}; + +export default SecondaryNavigation; diff --git a/client/src/protoFleet/components/SecondaryNavigation/index.ts b/client/src/protoFleet/components/SecondaryNavigation/index.ts new file mode 100644 index 000000000..ad89763d6 --- /dev/null +++ b/client/src/protoFleet/components/SecondaryNavigation/index.ts @@ -0,0 +1,3 @@ +import SecondaryNavigation from "./SecondaryNavigation"; + +export default SecondaryNavigation; diff --git a/client/src/protoFleet/components/SingleMinerWrapper/SingleMinerWrapper.tsx b/client/src/protoFleet/components/SingleMinerWrapper/SingleMinerWrapper.tsx new file mode 100644 index 000000000..c21f9ed76 --- /dev/null +++ b/client/src/protoFleet/components/SingleMinerWrapper/SingleMinerWrapper.tsx @@ -0,0 +1,41 @@ +import { ReactNode } from "react"; +import { Link, useParams } from "react-router-dom"; +import { MinerHostingProvider } from "@/protoOS/contexts/MinerHostingContext"; +import { DismissCircleDark } from "@/shared/assets/icons"; + +const CloseButton = ({ id }: { id: string }) => { + return ( + + + {id} + + ); +}; + +/** Encode the route param as a single safe path segment. Strips C0 control + * characters and whitespace, then re-encodes so /, \, .., ?, # etc. are + * never interpreted as URL structure when used in baseUrl or minerRoot. */ +// eslint-disable-next-line no-control-regex +const safePathSegment = (raw: string): string => encodeURIComponent(raw.replace(/[\x00-\x1f\x7f]/g, "")); + +const SingleMinerWrapper = ({ children }: { children: ReactNode }) => { + const { id: rawId } = useParams(); + const safeId = safePathSegment(rawId || ""); + const displayId = rawId || ""; + + // Here we are just setting the base url to /:id, + // which vite proxies to the actual miner api server. + // If we wanted to make this request to ProtoFleet backend we + // could pass /miners/:id instead + return ( + ) as ReactNode} + > + {children} + + ); +}; + +export default SingleMinerWrapper; diff --git a/client/src/protoFleet/components/SingleMinerWrapper/index.ts b/client/src/protoFleet/components/SingleMinerWrapper/index.ts new file mode 100644 index 000000000..298e4833a --- /dev/null +++ b/client/src/protoFleet/components/SingleMinerWrapper/index.ts @@ -0,0 +1,3 @@ +import SingleMinerWrapper from "./SingleMinerWrapper"; + +export default SingleMinerWrapper; diff --git a/client/src/protoFleet/components/StatusModal/StatusModal.tsx b/client/src/protoFleet/components/StatusModal/StatusModal.tsx new file mode 100644 index 000000000..bddb62798 --- /dev/null +++ b/client/src/protoFleet/components/StatusModal/StatusModal.tsx @@ -0,0 +1,272 @@ +import { useCallback, useMemo, useState } from "react"; +import { create } from "@bufbuild/protobuf"; +import type { ComponentAddress, ProtoFleetStatusModalProps } from "./types"; +import { + buildComponentStatusProps, + getComponentTitle, + mapErrorComponentTypeToShared, + transformErrorsForModal, + transformFleetErrorsToShared, +} from "./utils"; +import { ComponentType as ErrorComponentType, type ErrorMessage } from "@/protoFleet/api/generated/errors/v1/errors_pb"; +import { PairingStatus } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { StartMiningRequestSchema } from "@/protoFleet/api/generated/minercommand/v1/command_pb"; +import { DeviceStatus } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { useDeviceErrors } from "@/protoFleet/api/useDeviceErrors"; +import { useMinerCommand } from "@/protoFleet/api/useMinerCommand"; +import { createDeviceSelector } from "@/protoFleet/features/fleetManagement/utils/deviceSelector"; + +import { variants } from "@/shared/components/Button"; +import { StatusModal as SharedStatusModal } from "@/shared/components/StatusModal"; +import type { ComponentStatusData, MinerStatusData } from "@/shared/components/StatusModal/types"; +import { pushToast, STATUSES as TOAST_STATUSES, updateToast } from "@/shared/features/toaster"; +import { useMinerStatusSummary } from "@/shared/hooks/useStatusSummary"; + +// Stable empty array to avoid triggering useDeviceErrors internal effects on every render +const EMPTY_DEVICE_IDS: string[] = []; + +/** + * ProtoFleet-specific StatusModal wrapper that integrates with the store + * + * This component encapsulates all the integration logic between the ProtoFleet store + * and the shared StatusModal component. It handles: + * - Store data fetching and transformation + * - Component navigation state + * - Error grouping and formatting + * + * @example + * ```tsx + * const [isModalOpen, setModalOpen] = useState(false); + * + * setModalOpen(false)} + * deviceId={minerId} + * /> + * ``` + */ +const ProtoFleetStatusModal = ({ + open, + onClose, + deviceId, + miner, + componentAddress, + showBackButton = true, +}: ProtoFleetStatusModalProps) => { + const isVisible = open ?? true; + + // Component navigation state + const [component, setComponent] = useState(componentAddress); + + // Fetch errors for this device when modal is visible + const modalDeviceIds = useMemo(() => (isVisible && deviceId ? [deviceId] : EMPTY_DEVICE_IDS), [isVisible, deviceId]); + const { errorsByDevice } = useDeviceErrors(modalDeviceIds); + + const handleClose = useCallback(() => { + setComponent(componentAddress); + onClose(); + }, [componentAddress, onClose]); + + // Derive errors from the local fetch (not the store) + const allErrors = useMemo(() => (deviceId ? (errorsByDevice[deviceId] ?? []) : []), [errorsByDevice, deviceId]); + const groupedErrors = useMemo(() => { + const grouped = { + hashboard: [] as ErrorMessage[], + psu: [] as ErrorMessage[], + fan: [] as ErrorMessage[], + controlBoard: [] as ErrorMessage[], + other: [] as ErrorMessage[], + }; + allErrors.forEach((error) => { + switch (error.componentType) { + case ErrorComponentType.HASH_BOARD: + grouped.hashboard.push(error); + break; + case ErrorComponentType.PSU: + grouped.psu.push(error); + break; + case ErrorComponentType.FAN: + grouped.fan.push(error); + break; + case ErrorComponentType.CONTROL_BOARD: + grouped.controlBoard.push(error); + break; + default: + grouped.other.push(error); + break; + } + }); + return grouped; + }, [allErrors]); + + // Wake miner functionality + const { startMining } = useMinerCommand(); + + const handleWakeMiner = useCallback(() => { + if (!deviceId) return; + + const toastId = pushToast({ + message: "Waking miner...", + status: TOAST_STATUSES.loading, + longRunning: true, + }); + + const deviceSelector = createDeviceSelector("subset", [deviceId]); + const startMiningRequest = create(StartMiningRequestSchema, { + deviceSelector, + }); + + startMining({ + startMiningRequest, + onSuccess: () => { + updateToast(toastId, { + message: "Miner is waking up", + status: TOAST_STATUSES.success, + }); + onClose(); + }, + onError: (error) => { + updateToast(toastId, { + message: `Failed to wake miner: ${error}`, + status: TOAST_STATUSES.error, + }); + }, + }); + }, [deviceId, startMining, onClose]); + + // Transform ProtoFleet errors to shared format for status computation + const sharedErrors = useMemo(() => transformFleetErrorsToShared(groupedErrors), [groupedErrors]); + + // Determine status flags from DeviceStatus and PairingStatus + const needsAuthentication = miner?.pairingStatus === PairingStatus.AUTHENTICATION_NEEDED; + const isOffline = miner?.deviceStatus === DeviceStatus.OFFLINE; + // When authentication is needed, we can't trust INACTIVE (or MAINTENANCE) status + // (could be sleeping OR showing as inactive/maintenance because we can't authenticate) + const isSleeping = + (miner?.deviceStatus === DeviceStatus.INACTIVE || miner?.deviceStatus === DeviceStatus.MAINTENANCE) && + !needsAuthentication; + const needsMiningPool = miner?.deviceStatus === DeviceStatus.NEEDS_MINING_POOL; + + // Compute summary using shared hook (replaces API-provided summary) + const summary = useMinerStatusSummary(sharedErrors, isSleeping, isOffline, needsAuthentication, needsMiningPool); + + // getMinerStatus function - returns complete data including config + const getMinerStatus = useCallback((): MinerStatusData => { + // Create onClick handler that navigates to component details + const onClickHandler = (deviceId: string, type: ErrorComponentType, componentId: string) => { + setComponent({ deviceId, componentType: type, componentId }); + }; + + // Transform grouped errors with click handlers + const errorsBySource = { + hashboard: transformErrorsForModal(groupedErrors.hashboard || [], deviceId, onClickHandler), + psu: transformErrorsForModal(groupedErrors.psu || [], deviceId, onClickHandler), + fan: transformErrorsForModal(groupedErrors.fan || [], deviceId, onClickHandler), + controlBoard: transformErrorsForModal(groupedErrors.controlBoard || [], deviceId, onClickHandler), + other: transformErrorsForModal(groupedErrors.other || [], deviceId, onClickHandler), + }; + + // Check if miner is sleeping (offline state in fleet context) + // Don't show wake button if authentication is needed (can't trust INACTIVE/MAINTENANCE status) + const isMinersleeping = + (miner?.deviceStatus === DeviceStatus.INACTIVE || miner?.deviceStatus === DeviceStatus.MAINTENANCE) && + !needsAuthentication; + + // Build buttons + const buttons = []; + + // Add wake miner button if miner is sleeping + if (isMinersleeping) { + buttons.push({ + text: "Wake miner", + variant: variants.secondary, + onClick: () => { + handleClose(); + handleWakeMiner(); + }, + }); + } + + buttons.push({ + text: "Done", + variant: variants.primary, + onClick: handleClose, + }); + + return { + props: { + title: summary.title, + subtitle: summary.subtitle, + errors: errorsBySource, + isSleeping: isMinersleeping, + isOffline, + needsAuthentication, + needsMiningPool, + }, + title: `${miner?.name || deviceId} status`, + buttons, + onDismiss: handleClose, + }; + }, [ + groupedErrors, + summary, + miner, + deviceId, + handleWakeMiner, + handleClose, + isOffline, + needsAuthentication, + needsMiningPool, + ]); + + // getComponentStatus function - returns complete data including config + const getComponentStatus = useCallback( + (address: ComponentAddress): ComponentStatusData | undefined => { + const { componentType: type, componentId: id } = address; + + // Build component status props using the miner data and errors + const props = buildComponentStatusProps(miner, type, id, allErrors); + + if (!props) { + // Return undefined if component not found + return undefined; + } + + const sharedType = mapErrorComponentTypeToShared(type); + if (!sharedType) return undefined; + + return { + props, + title: getComponentTitle(sharedType), + buttons: [ + { + text: "Done", + variant: variants.primary, + onClick: handleClose, + }, + ], + onDismiss: handleClose, + onNavigateBack: () => setComponent(undefined), + }; + }, + [miner, handleClose, allErrors], + ); + + // Don't render if no miner data + if (!miner) { + return null; + } + + // Render the shared StatusModal with integration data + return ( + + ); +}; + +export default ProtoFleetStatusModal; diff --git a/client/src/protoFleet/components/StatusModal/constants.ts b/client/src/protoFleet/components/StatusModal/constants.ts new file mode 100644 index 000000000..b738770d0 --- /dev/null +++ b/client/src/protoFleet/components/StatusModal/constants.ts @@ -0,0 +1,59 @@ +import { ComponentType as ErrorComponentType } from "@/protoFleet/api/generated/errors/v1/errors_pb"; +import type { ComponentType } from "@/shared/components/StatusModal/types"; + +/** + * Mapping from error API component types to shared component types + * Only includes supported component types - unsupported types will return undefined + */ +export const ERROR_COMPONENT_TO_SHARED: Partial> = { + [ErrorComponentType.HASH_BOARD]: "hashboard", + [ErrorComponentType.PSU]: "psu", + [ErrorComponentType.FAN]: "fan", + [ErrorComponentType.CONTROL_BOARD]: "controlBoard", +}; + +/** + * Mapping from shared component types to error API component types + */ +export const SHARED_TO_ERROR_COMPONENT: Record = { + hashboard: ErrorComponentType.HASH_BOARD, + psu: ErrorComponentType.PSU, + fan: ErrorComponentType.FAN, + controlBoard: ErrorComponentType.CONTROL_BOARD, + other: ErrorComponentType.UNSPECIFIED, +}; + +/** + * Component display titles + */ +export const COMPONENT_TITLES: Record = { + fan: "Fan status", + hashboard: "Hashboard status", + psu: "PSU status", + controlBoard: "Control board status", + other: "Needs attention", +}; + +/** + * Component names (without "status") + */ +export const COMPONENT_NAMES: Record = { + fan: "Fan", + hashboard: "Hashboard", + psu: "PSU", + controlBoard: "Control board", + other: "Needs attention", +}; + +/** + * Set of component types that are supported in the UI + * Components not in this set (like EEPROM, IO_MODULE) will be ignored since they are not yet accommodated in the UI + * + * TODO: Add support for these component types in the UI + */ +export const SUPPORTED_COMPONENT_TYPES = new Set([ + ErrorComponentType.HASH_BOARD, + ErrorComponentType.PSU, + ErrorComponentType.FAN, + ErrorComponentType.CONTROL_BOARD, +]); diff --git a/client/src/protoFleet/components/StatusModal/hooks/index.ts b/client/src/protoFleet/components/StatusModal/hooks/index.ts new file mode 100644 index 000000000..a272ba329 --- /dev/null +++ b/client/src/protoFleet/components/StatusModal/hooks/index.ts @@ -0,0 +1,2 @@ +// Re-export StatusModal-specific types +export type { ComponentHardware } from "./useStatusModalHooks"; diff --git a/client/src/protoFleet/components/StatusModal/hooks/useStatusModalHooks.ts b/client/src/protoFleet/components/StatusModal/hooks/useStatusModalHooks.ts new file mode 100644 index 000000000..9ead3d60b --- /dev/null +++ b/client/src/protoFleet/components/StatusModal/hooks/useStatusModalHooks.ts @@ -0,0 +1,5 @@ +export type ComponentHardware = { + model?: string; + serialNumber?: string; + firmwareVersion?: string; +}; diff --git a/client/src/protoFleet/components/StatusModal/index.ts b/client/src/protoFleet/components/StatusModal/index.ts new file mode 100644 index 000000000..64814ae62 --- /dev/null +++ b/client/src/protoFleet/components/StatusModal/index.ts @@ -0,0 +1,2 @@ +export { default as ProtoFleetStatusModal } from "./StatusModal"; +export type { ProtoFleetStatusModalProps, ComponentAddress } from "./types"; diff --git a/client/src/protoFleet/components/StatusModal/types.ts b/client/src/protoFleet/components/StatusModal/types.ts new file mode 100644 index 000000000..518777bb3 --- /dev/null +++ b/client/src/protoFleet/components/StatusModal/types.ts @@ -0,0 +1,44 @@ +/** + * ProtoFleet-specific StatusModal types + */ + +import type { ComponentType as ErrorComponentType } from "@/protoFleet/api/generated/errors/v1/errors_pb"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +/** + * Component address for navigation to ComponentStatusModal + * In ProtoFleet, we use the component type from the errors API + * deviceId is included to ensure uniqueness across devices + * componentId is the ID from the API (currently index as string, will be unique ID in future) + */ +export interface ComponentAddress { + deviceId: string; + componentType: ErrorComponentType; + componentId: string; // Component ID from API (for RESULT_VIEW_COMPONENT calls) +} + +/** + * Props for the ProtoFleet StatusModal wrapper component + * + * This wrapper encapsulates all integration logic with the ProtoFleet store + * and provides a simple API for consumers. + */ +export interface ProtoFleetStatusModalProps { + /** Controls modal visibility */ + open?: boolean; + + /** Callback when modal should be closed */ + onClose: () => void; + + /** The device identifier (miner ID) to show status for */ + deviceId: string; + + /** Optional miner data — if not provided, status info will be limited */ + miner?: MinerStateSnapshot; + + /** Optional initial component to display (defaults to miner view) */ + componentAddress?: ComponentAddress; + + /** Whether to show back button in component views (defaults to true) */ + showBackButton?: boolean; +} diff --git a/client/src/protoFleet/components/StatusModal/utils.ts b/client/src/protoFleet/components/StatusModal/utils.ts new file mode 100644 index 000000000..83e8dec9e --- /dev/null +++ b/client/src/protoFleet/components/StatusModal/utils.ts @@ -0,0 +1,238 @@ +import { timestampMs } from "@bufbuild/protobuf/wkt"; +import { + COMPONENT_NAMES, + COMPONENT_TITLES, + ERROR_COMPONENT_TO_SHARED, + SHARED_TO_ERROR_COMPONENT, + SUPPORTED_COMPONENT_TYPES, +} from "./constants"; +import { ComponentType as ErrorComponentType } from "@/protoFleet/api/generated/errors/v1/errors_pb"; +import type { ErrorMessage } from "@/protoFleet/api/generated/errors/v1/errors_pb"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import type { + ComponentMetadata, + ComponentMetric, + ComponentStatusModalProps, + ComponentType, + ErrorData, +} from "@/shared/components/StatusModal/types"; +import { + computeComponentStatusTitle, + type GroupedStatusErrors, + type StatusComponentType, +} from "@/shared/hooks/useStatusSummary"; + +/** + * Get the display title for a component type + */ +export const getComponentTitle = (type: ComponentType): string => { + return COMPONENT_TITLES[type]; +}; + +/** + * Get the component name (without "status") + */ +export const getComponentName = (type: ComponentType): string => { + return COMPONENT_NAMES[type]; +}; + +/** + * Maps error API component type to shared component type + */ +export function mapErrorComponentTypeToShared(type: ErrorComponentType): ComponentType | null { + return ERROR_COMPONENT_TO_SHARED[type] ?? null; +} + +/** + * Maps shared component type to error API component type + */ +export function mapSharedToErrorComponentType(type: ComponentType): ErrorComponentType { + return SHARED_TO_ERROR_COMPONENT[type]; +} + +/** + * Type for grouped fleet errors returned by useGroupedErrors hook + */ +export type GroupedFleetErrors = { + hashboard: ErrorMessage[]; + psu: ErrorMessage[]; + fan: ErrorMessage[]; + controlBoard: ErrorMessage[]; + other: ErrorMessage[]; +}; + +/** + * Transform ProtoFleet grouped errors to shared format for status computation + */ +export function transformFleetErrorsToShared(groupedErrors: GroupedFleetErrors): GroupedStatusErrors { + const transformErrors = (errors: ErrorMessage[], componentType: StatusComponentType) => + errors.map((e) => { + // For "other" errors, don't include slot - componentId may be a pool index or other identifier + if (componentType === "other") { + return { componentType, slot: undefined }; + } + const parsed = e.componentId ? parseInt(e.componentId, 10) : NaN; + // componentId is already 1-based slot from firmware + return { + componentType, + slot: !isNaN(parsed) ? parsed : undefined, + }; + }); + + return { + hashboard: transformErrors(groupedErrors.hashboard, "hashboard"), + psu: transformErrors(groupedErrors.psu, "psu"), + fan: transformErrors(groupedErrors.fan, "fan"), + controlBoard: transformErrors(groupedErrors.controlBoard, "controlBoard"), + other: transformErrors(groupedErrors.other, "other"), + }; +} + +/** + * Get display index from component ID for UI display purposes only + * ComponentId contains 1-based slot values from firmware ("1", "2", "3") + * This will change when componentId becomes a unique ID + */ +export function getComponentDisplayIndex(componentId: string): number | null { + // componentId is already 1-based slot from firmware, use as-is for display + const slot = parseInt(componentId, 10); + return isNaN(slot) ? null : slot; +} + +/** + * Transforms errors array to ErrorData format for shared/components/StatusModal + * Groups errors by component and creates ErrorData objects + * Only creates onClick handlers when componentId exists + */ +export function transformErrorsForModal( + errors: ErrorMessage[], + deviceId: string, + onClick?: (deviceId: string, type: ErrorComponentType, componentId: string) => void, +): ErrorData[] { + const result: ErrorData[] = []; + + errors.forEach((error) => { + let componentName = "Unknown Component"; + let componentClickHandler: (() => void) | undefined; + + // Check if error has a supported componentType + if (error.componentType && SUPPORTED_COMPONENT_TYPES.has(error.componentType)) { + const sharedType = mapErrorComponentTypeToShared(error.componentType); + + if (sharedType) { + // Check if we have componentId for display and onClick + if (error.componentId) { + const componentIdValue = error.componentId; // Capture value for closure + const displayIndex = getComponentDisplayIndex(componentIdValue); + + componentName = displayIndex + ? `${getComponentName(sharedType)} ${displayIndex}` + : getComponentName(sharedType); + + // Create onClick handler with componentId + if (onClick) { + componentClickHandler = () => onClick(deviceId, error.componentType, componentIdValue); + } + } else { + // No componentId - just show component type without index + componentName = getComponentName(sharedType); + // No onClick handler since we can't navigate without componentId + } + } + } else { + // Handle unsupported or missing component types as "other" + componentName = getComponentName("other"); + } + + // Handle timestamp conversion - convert to seconds for shared formatters + let timestamp: number | undefined; + if (error.lastSeenAt) { + timestamp = Math.floor(timestampMs(error.lastSeenAt) / 1000); + } + + // Create ErrorData object with the expected structure + result.push({ + componentName, + message: error.summary || "Unknown error", + timestamp, + onClick: componentClickHandler, + }); + }); + + return result; +} + +/** + * Build component status props from fleet data + */ +export function buildComponentStatusProps( + miner: MinerStateSnapshot | undefined, + componentType: ErrorComponentType, + componentId: string, + allErrors?: ErrorMessage[], // Pass errors from normalized store +): ComponentStatusModalProps | undefined { + if (!miner) return undefined; + + const sharedType = mapErrorComponentTypeToShared(componentType); + if (!sharedType) return undefined; + + // Get display index for UI + const displayIndex = getComponentDisplayIndex(componentId); + + // Get component-specific errors (only for supported component types) + const componentErrors = + allErrors?.filter((error) => { + return ( + error.componentType === componentType && + error.componentId === componentId && + SUPPORTED_COMPONENT_TYPES.has(componentType) + ); + }) || []; + + // Transform errors to the shared format + const errors: ErrorData[] = componentErrors.map((error) => { + // Handle timestamp conversion - convert to seconds for shared formatters + let timestamp: number | undefined; + if (error.lastSeenAt) { + timestamp = Math.floor(timestampMs(error.lastSeenAt) / 1000); + } + + return { + componentName: `${getComponentName(sharedType)} ${displayIndex}`, + message: error.summary || "Unknown error", + timestamp, + }; + }); + + // No component-level telemetry metrics available + // TODO: Backend only collects miner-level aggregated telemetry + const telemetry: ComponentMetric[] = []; + + // Build metadata + const metadata: ComponentMetadata = { + component: { + label: "Component", + value: `${getComponentName(sharedType)} ${displayIndex}`, + }, + device: { + label: "Device", + value: miner.name || miner.deviceIdentifier, + }, + }; + + if (miner.model) { + metadata.model = { label: "Model", value: miner.model }; + } + + // Compute summary using shared logic + const summary = + computeComponentStatusTitle(sharedType, displayIndex ?? undefined, componentErrors.length) ?? undefined; + + return { + componentType: sharedType, + summary, + metrics: telemetry, + errors, + metadata, + }; +} diff --git a/client/src/protoFleet/config/navItems.ts b/client/src/protoFleet/config/navItems.ts new file mode 100644 index 000000000..1d8165aab --- /dev/null +++ b/client/src/protoFleet/config/navItems.ts @@ -0,0 +1,90 @@ +import { type ReactNode } from "react"; + +import { Activity, Fleet, Groups, Home, IconProps, Racks, Settings } from "@/shared/assets/icons"; + +export interface NavItem { + path: string; + label: string; + icon?: (i: IconProps) => ReactNode; +} + +export interface SecondaryNavItem { + path: string; + label: string; + parent: string; + allowedRoles?: string[]; +} + +// Primary navigation items (shown in main nav menu) +export const primaryNavItems: NavItem[] = [ + { + path: "/", + label: "Home", + icon: Home, + }, + { + path: "/miners", + label: "Miners", + icon: Fleet, + }, + { + path: "/groups", + label: "Groups", + icon: Groups, + }, + { + path: "/racks", + label: "Racks", + icon: Racks, + }, + { + path: "/activity", + label: "Activity", + icon: Activity, + }, + { + path: "/settings", + label: "Settings", + icon: Settings, + }, +]; + +// Secondary navigation items (shown in settings submenu) +export const secondaryNavItems: SecondaryNavItem[] = [ + { + path: "/settings/general", + label: "General", + parent: "/settings", + }, + { + path: "/settings/security", + label: "Security", + parent: "/settings", + }, + { + path: "/settings/team", + label: "Team", + parent: "/settings", + }, + { + path: "/settings/mining-pools", + label: "Pools", + parent: "/settings", + }, + { + path: "/settings/firmware", + label: "Firmware", + parent: "/settings", + }, + { + path: "/settings/schedules", + label: "Schedules", + parent: "/settings", + }, + { + path: "/settings/api-keys", + label: "API Keys", + parent: "/settings", + allowedRoles: ["SUPER_ADMIN", "ADMIN"], + }, +]; diff --git a/client/src/protoFleet/constants/polling.ts b/client/src/protoFleet/constants/polling.ts new file mode 100644 index 000000000..5e16a0c78 --- /dev/null +++ b/client/src/protoFleet/constants/polling.ts @@ -0,0 +1,6 @@ +/** + * Shared polling interval for refreshing data across all ProtoFleet pages. + * Can be overridden via VITE_POLL_INTERVAL_MS environment variable. + * Default: 60 seconds + */ +export const POLL_INTERVAL_MS = Number(import.meta.env.VITE_POLL_INTERVAL_MS) || 60000; diff --git a/client/src/protoFleet/features/activity/components/ActivityFilters.tsx b/client/src/protoFleet/features/activity/components/ActivityFilters.tsx new file mode 100644 index 000000000..b6f494c7d --- /dev/null +++ b/client/src/protoFleet/features/activity/components/ActivityFilters.tsx @@ -0,0 +1,97 @@ +import { useCallback, useMemo } from "react"; + +import type { EventTypeOption, UserOption } from "@/protoFleet/api/generated/activity/v1/activity_pb"; +import { formatLabel } from "@/protoFleet/features/activity/utils/formatLabel"; +import Input from "@/shared/components/Input"; +import DropdownFilter from "@/shared/components/List/Filters/DropdownFilter"; + +interface ActivityFiltersProps { + searchValue: string; + onSearchChange: (value: string) => void; + eventTypes: EventTypeOption[]; + scopeTypes: string[]; + users: UserOption[]; + selectedTypes: string[]; + selectedScopes: string[]; + selectedUsers: string[]; + onTypesChange: (types: string[]) => void; + onScopesChange: (scopes: string[]) => void; + onUsersChange: (users: string[]) => void; +} + +const ActivityFilters = ({ + searchValue, + onSearchChange, + eventTypes, + scopeTypes, + users, + selectedTypes, + selectedScopes, + selectedUsers, + onTypesChange, + onScopesChange, + onUsersChange, +}: ActivityFiltersProps) => { + const typeOptions = useMemo( + () => eventTypes.map((et) => ({ id: et.eventType, label: formatLabel(et.eventType) })), + [eventTypes], + ); + + const scopeOptions = useMemo(() => scopeTypes.map((st) => ({ id: st, label: formatLabel(st) })), [scopeTypes]); + + const userOptions = useMemo(() => users.map((u) => ({ id: u.userId, label: u.username })), [users]); + + const handleClearSearch = useCallback( + (key: string) => { + if (key === "Escape") { + onSearchChange(""); + } + }, + [onSearchChange], + ); + + return ( +
+
+ onSearchChange(value)} + onKeyDown={handleClearSearch} + /> +
+ {typeOptions.length > 0 && ( + + )} + {scopeOptions.length > 0 && ( + + )} + {userOptions.length > 0 && ( + + )} +
+ ); +}; + +export default ActivityFilters; diff --git a/client/src/protoFleet/features/activity/components/ActivityTable.tsx b/client/src/protoFleet/features/activity/components/ActivityTable.tsx new file mode 100644 index 000000000..e27b5abaf --- /dev/null +++ b/client/src/protoFleet/features/activity/components/ActivityTable.tsx @@ -0,0 +1,84 @@ +import React, { useMemo } from "react"; +import clsx from "clsx"; + +import type { ActivityEntry } from "@/protoFleet/api/generated/activity/v1/activity_pb"; +import { getActivityIcon } from "@/protoFleet/features/activity/utils/activityIcons"; +import { formatScope } from "@/protoFleet/features/activity/utils/formatScope"; +import { Alert } from "@/shared/assets/icons"; +import List from "@/shared/components/List"; +import type { ColConfig, ColTitles } from "@/shared/components/List/types"; +import { formatActivityTimestamp } from "@/shared/utils/formatTimestamp"; + +type ActivityColumns = "type" | "scope" | "user" | "timestamp"; + +const colTitles: ColTitles = { + type: "Type", + scope: "Scope", + user: "User", + timestamp: "Timestamp", +}; + +const activeCols: ActivityColumns[] = ["type", "scope", "user", "timestamp"]; + +const defaultNoDataElement =
No activity to display.
; + +interface ActivityTableProps { + activities: ActivityEntry[]; + totalCount: number; + noDataElement?: React.ReactNode; +} + +const ActivityTable = ({ activities, totalCount, noDataElement }: ActivityTableProps) => { + const colConfig: ColConfig = useMemo( + () => ({ + type: { + component: (entry) => { + const isFailed = entry.result === "failure"; + const Icon = isFailed ? Alert : getActivityIcon(entry.eventType); + return ( +
+
+ +
+ {entry.description} + {isFailed && Failed} +
+ ); + }, + width: "min-w-80", + allowWrap: true, + }, + scope: { + component: (entry) => ( + {formatScope(entry.scopeType, entry.scopeLabel, entry.scopeCount || undefined)} + ), + width: "w-48", + }, + user: { + component: (entry) => {entry.username ?? "\u2014"}, + width: "w-40", + }, + timestamp: { + component: (entry) => {formatActivityTimestamp(Number(entry.createdAt?.seconds))}, + width: "w-40", + }, + }), + [], + ); + + return ( + + items={activities} + itemKey="eventId" + activeCols={activeCols} + colTitles={colTitles} + colConfig={colConfig} + total={totalCount} + stickyFirstColumn={false} + itemName={{ singular: "activity", plural: "activities" }} + noDataElement={noDataElement ?? defaultNoDataElement} + /> + ); +}; + +export default ActivityTable; diff --git a/client/src/protoFleet/features/activity/index.ts b/client/src/protoFleet/features/activity/index.ts new file mode 100644 index 000000000..54068e579 --- /dev/null +++ b/client/src/protoFleet/features/activity/index.ts @@ -0,0 +1 @@ +export { default as ActivityPage } from "./pages/ActivityPage"; diff --git a/client/src/protoFleet/features/activity/pages/ActivityPage.tsx b/client/src/protoFleet/features/activity/pages/ActivityPage.tsx new file mode 100644 index 000000000..fd764aff0 --- /dev/null +++ b/client/src/protoFleet/features/activity/pages/ActivityPage.tsx @@ -0,0 +1,203 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { create } from "@bufbuild/protobuf"; + +import { ActivityFilterSchema } from "@/protoFleet/api/generated/activity/v1/activity_pb"; +import { useActivity } from "@/protoFleet/api/useActivity"; +import { useActivityFilterOptions } from "@/protoFleet/api/useActivityFilterOptions"; +import { useExportActivity } from "@/protoFleet/api/useExportActivity"; +import NoFilterResultsEmptyState from "@/protoFleet/components/NoFilterResultsEmptyState"; +import ActivityFilters from "@/protoFleet/features/activity/components/ActivityFilters"; +import ActivityTable from "@/protoFleet/features/activity/components/ActivityTable"; +import { formatLabel } from "@/protoFleet/features/activity/utils/formatLabel"; +import { Alert, DismissTiny } from "@/shared/assets/icons"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import Callout from "@/shared/components/Callout"; +import Header from "@/shared/components/Header"; +import ProgressCircular from "@/shared/components/ProgressCircular"; +import { debounce } from "@/shared/utils/utility"; + +const PAGE_SIZE = 50; + +const ActivityPage = () => { + const [searchText, setSearchText] = useState(""); + const [debouncedSearchText, setDebouncedSearchText] = useState(""); + const [selectedTypes, setSelectedTypes] = useState([]); + const [selectedScopes, setSelectedScopes] = useState([]); + const [selectedUsers, setSelectedUsers] = useState([]); + + const debouncedSetSearch = useMemo(() => debounce((text: string) => setDebouncedSearchText(text), 300), []); + useEffect(() => () => debouncedSetSearch.cancel(), [debouncedSetSearch]); + + const handleSearchChange = useCallback( + (value: string) => { + setSearchText(value); + if (value === "") { + debouncedSetSearch.cancel(); + setDebouncedSearchText(""); + } else { + debouncedSetSearch(value); + } + }, + [debouncedSetSearch], + ); + + const filter = useMemo( + () => + create(ActivityFilterSchema, { + eventTypes: selectedTypes, + scopeTypes: selectedScopes, + userIds: selectedUsers, + searchText: debouncedSearchText, + }), + [selectedTypes, selectedScopes, selectedUsers, debouncedSearchText], + ); + + const { activities, totalCount, isLoading, error, hasMore, loadMore } = useActivity({ + filter, + pageSize: PAGE_SIZE, + }); + const { exportCsv, isExportingCsv } = useExportActivity(); + const { eventTypes, scopeTypes, users } = useActivityFilterOptions(); + + const [hasLoaded, setHasLoaded] = useState(false); + const hasStartedLoadingRef = useRef(false); + /* eslint-disable react-hooks/set-state-in-effect */ + useEffect(() => { + if (isLoading) { + hasStartedLoadingRef.current = true; + } else if (hasStartedLoadingRef.current && !hasLoaded) { + setHasLoaded(true); + } + }, [isLoading, hasLoaded]); + /* eslint-enable react-hooks/set-state-in-effect */ + + const isInitialLoad = isLoading && activities.length === 0 && !hasLoaded; + const isLoadingMore = isLoading && activities.length > 0; + + const hasActiveFilters = + selectedTypes.length > 0 || selectedScopes.length > 0 || selectedUsers.length > 0 || debouncedSearchText !== ""; + + const handleClearFilters = useCallback(() => { + setSearchText(""); + setDebouncedSearchText(""); + debouncedSetSearch.cancel(); + setSelectedTypes([]); + setSelectedScopes([]); + setSelectedUsers([]); + }, [debouncedSetSearch]); + + const handleRemoveType = useCallback((id: string) => setSelectedTypes((prev) => prev.filter((t) => t !== id)), []); + + const handleRemoveScope = useCallback((id: string) => setSelectedScopes((prev) => prev.filter((s) => s !== id)), []); + + const handleRemoveUser = useCallback((id: string) => setSelectedUsers((prev) => prev.filter((u) => u !== id)), []); + + const activeFilterPills = useMemo(() => { + const pills: { key: string; label: string; onRemove: () => void }[] = []; + for (const id of selectedTypes) { + pills.push({ key: `type-${id}`, label: formatLabel(id), onRemove: () => handleRemoveType(id) }); + } + for (const id of selectedScopes) { + pills.push({ key: `scope-${id}`, label: formatLabel(id), onRemove: () => handleRemoveScope(id) }); + } + for (const id of selectedUsers) { + const user = users.find((u) => u.userId === id); + pills.push({ + key: `user-${id}`, + label: user?.username ?? id, + onRemove: () => handleRemoveUser(id), + }); + } + return pills; + }, [selectedTypes, selectedScopes, selectedUsers, users, handleRemoveType, handleRemoveScope, handleRemoveUser]); + + if (isInitialLoad) { + return ( +
+ +
+ ); + } + + return ( + <> +
+
+
+ +
+
+ + {activeFilterPills.length > 0 && ( +
+ {activeFilterPills.map((pill) => ( + + ))} +
+ )} +
+
+ + {error ? ( + } title={error} /> + ) : null} + +
+ + ) : hasActiveFilters ? ( + + ) : undefined + } + /> + {hasMore && ( +
+ +
+ )} +
+ + ); +}; + +export default ActivityPage; diff --git a/client/src/protoFleet/features/activity/utils/activityIcons.tsx b/client/src/protoFleet/features/activity/utils/activityIcons.tsx new file mode 100644 index 000000000..7ae988d64 --- /dev/null +++ b/client/src/protoFleet/features/activity/utils/activityIcons.tsx @@ -0,0 +1,67 @@ +import { type ReactNode } from "react"; + +import { + Alert, + Edit, + Fan, + Groups, + type IconProps, + Info, + LEDIndicator, + Lock, + Logs, + MiningPools, + Minus, + Plus, + Power, + Racks, + Reboot, + Settings, + Speedometer, + Trash, + Unpair, +} from "@/shared/assets/icons"; + +const iconMap: Record ReactNode> = { + login: Lock, + login_failed: Alert, + logout: Lock, + update_password: Lock, + update_username: Lock, + create_user: Lock, + deactivate_user: Trash, + reset_password: Lock, + create_admin_user: Lock, + + stop_mining: Power, + start_mining: Power, + reboot: Reboot, + blink_led: LEDIndicator, + download_logs: Logs, + set_power_target: Speedometer, + set_cooling_mode: Fan, + update_mining_pools: MiningPools, + update_miner_password: Lock, + firmware_update: Settings, + unpair: Unpair, + + unpair_miners: Unpair, + rename_miners: Edit, + + create_collection: Groups, + update_collection: Groups, + delete_collection: Trash, + add_devices: Plus, + remove_devices: Minus, + set_rack_slot: Racks, + clear_rack_slot: Racks, + save_rack: Racks, + + create_pool: MiningPools, + update_pool: MiningPools, + delete_pool: Trash, +}; + +export function getActivityIcon(eventType: string): (props: IconProps) => ReactNode { + return iconMap[eventType] ?? Info; +} diff --git a/client/src/protoFleet/features/activity/utils/formatLabel.ts b/client/src/protoFleet/features/activity/utils/formatLabel.ts new file mode 100644 index 000000000..caeb0c341 --- /dev/null +++ b/client/src/protoFleet/features/activity/utils/formatLabel.ts @@ -0,0 +1 @@ +export const formatLabel = (str: string) => str.replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase()); diff --git a/client/src/protoFleet/features/activity/utils/formatScope.ts b/client/src/protoFleet/features/activity/utils/formatScope.ts new file mode 100644 index 000000000..e492e466a --- /dev/null +++ b/client/src/protoFleet/features/activity/utils/formatScope.ts @@ -0,0 +1,13 @@ +export function formatScope(_scopeType?: string, scopeLabel?: string, scopeCount?: number): string { + if (!scopeLabel && !scopeCount) return "\u2014"; + if (scopeLabel && scopeCount) { + const unit = scopeCount === 1 ? "miner" : "miners"; + return `${scopeLabel} (${scopeCount} ${unit})`; + } + if (scopeLabel) return scopeLabel; + if (scopeCount) { + const unit = scopeCount === 1 ? "miner" : "miners"; + return `${scopeCount} ${unit}`; + } + return "\u2014"; +} diff --git a/client/src/protoFleet/features/auth/components/AuthenticateFleetModal/AuthenticateFleetModal.stories.tsx b/client/src/protoFleet/features/auth/components/AuthenticateFleetModal/AuthenticateFleetModal.stories.tsx new file mode 100644 index 000000000..00658c9d3 --- /dev/null +++ b/client/src/protoFleet/features/auth/components/AuthenticateFleetModal/AuthenticateFleetModal.stories.tsx @@ -0,0 +1,91 @@ +import { useState } from "react"; +import { action } from "storybook/actions"; +import AuthenticateFleetModal from "./AuthenticateFleetModal"; + +export default { + title: "Proto Fleet/Auth/AuthenticateFleetModal", + component: AuthenticateFleetModal, +}; + +export const Default = () => { + const [open, setOpen] = useState(true); + + return ( + <> + {!open && ( +
+ +
+ )} + { + action("onAuthenticated")({ username, password }); + setOpen(false); + }} + onDismiss={() => { + action("onDismiss")(); + setOpen(false); + }} + /> + + ); +}; + +export const SecurityPurpose = () => { + const [open, setOpen] = useState(true); + + return ( + <> + {!open && ( +
+ +
+ )} + { + action("onAuthenticated")({ username, password }); + setOpen(false); + }} + onDismiss={() => { + action("onDismiss")(); + setOpen(false); + }} + /> + + ); +}; + +export const PoolPurpose = () => { + const [open, setOpen] = useState(true); + + return ( + <> + {!open && ( +
+ +
+ )} + { + action("onAuthenticated")({ username, password }); + setOpen(false); + }} + onDismiss={() => { + action("onDismiss")(); + setOpen(false); + }} + /> + + ); +}; diff --git a/client/src/protoFleet/features/auth/components/AuthenticateFleetModal/AuthenticateFleetModal.tsx b/client/src/protoFleet/features/auth/components/AuthenticateFleetModal/AuthenticateFleetModal.tsx new file mode 100644 index 000000000..ed196c780 --- /dev/null +++ b/client/src/protoFleet/features/auth/components/AuthenticateFleetModal/AuthenticateFleetModal.tsx @@ -0,0 +1,131 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { authClient } from "@/protoFleet/api/clients"; +import { Alert } from "@/shared/assets/icons"; +import { variants } from "@/shared/components/Button"; +import ButtonGroup from "@/shared/components/ButtonGroup"; +import { groupVariants } from "@/shared/components/ButtonGroup/constants"; +import Callout from "@/shared/components/Callout"; +import Input from "@/shared/components/Input"; +import Modal from "@/shared/components/Modal/Modal"; + +interface AuthenticateFleetModalProps { + open: boolean; + purpose?: "security" | "pool" | "workerNames"; + onAuthenticated: (username: string, password: string) => void; + onDismiss: () => void; +} + +const modalTitlesByPurpose = { + security: "Log in to update your security settings", + pool: "Log in to update your pool settings", + workerNames: "Log in to update worker names", +} satisfies Record, string>; + +const AuthenticateFleetModal = ({ open, purpose, onAuthenticated, onDismiss }: AuthenticateFleetModalProps) => { + const title = purpose ? modalTitlesByPurpose[purpose] : "Log in to update settings"; + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + const [isVerifying, setIsVerifying] = useState(false); + + // Reset form when modal is dismissed + useEffect(() => { + if (!open) { + setUsername(""); + setPassword(""); + setErrorMessage(""); + setIsVerifying(false); + } + }, [open]); + + const canContinue = username && password && !isVerifying; + + const handleContinue = useCallback(async () => { + // Clear previous error + setErrorMessage(""); + + // Validate fields + if (!username || !password) { + setErrorMessage("Username and password are required"); + return; + } + + setIsVerifying(true); + + try { + await authClient.verifyCredentials({ username, password }); + + // If successful, call onAuthenticated with the credentials + onAuthenticated(username, password); + } catch { + setErrorMessage("Invalid credentials entered."); + } finally { + setIsVerifying(false); + } + }, [username, password, onAuthenticated]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && canContinue) { + e.preventDefault(); + handleContinue(); + } + }, + [canContinue, handleContinue], + ); + + return ( + + {errorMessage ? } title={errorMessage} /> : null} + +
+ setUsername(value)} + disabled={isVerifying} + autoFocus + /> + + setPassword(value)} + disabled={isVerifying} + /> +
+ + +
+ ); +}; + +export default AuthenticateFleetModal; diff --git a/client/src/protoFleet/features/auth/components/AuthenticateFleetModal/index.ts b/client/src/protoFleet/features/auth/components/AuthenticateFleetModal/index.ts new file mode 100644 index 000000000..bb87780d9 --- /dev/null +++ b/client/src/protoFleet/features/auth/components/AuthenticateFleetModal/index.ts @@ -0,0 +1 @@ +export { default } from "./AuthenticateFleetModal"; diff --git a/client/src/protoFleet/features/auth/components/AuthenticateMiners/AuthenticateMiners.stories.tsx b/client/src/protoFleet/features/auth/components/AuthenticateMiners/AuthenticateMiners.stories.tsx new file mode 100644 index 000000000..bb5d92baa --- /dev/null +++ b/client/src/protoFleet/features/auth/components/AuthenticateMiners/AuthenticateMiners.stories.tsx @@ -0,0 +1,32 @@ +import { useState } from "react"; +import { action } from "storybook/actions"; +import AuthenticateMiners from "./AuthenticateMiners"; + +export default { + title: "Proto Fleet/Auth/AuthenticateMiners", + component: AuthenticateMiners, +}; + +export const Default = () => { + const [open, setOpen] = useState(true); + + return ( + <> + {!open && ( +
+ +
+ )} + { + action("onClose")(); + setOpen(false); + }} + onSuccess={() => action("onSuccess")()} + /> + + ); +}; diff --git a/client/src/protoFleet/features/auth/components/AuthenticateMiners/AuthenticateMiners.test.tsx b/client/src/protoFleet/features/auth/components/AuthenticateMiners/AuthenticateMiners.test.tsx new file mode 100644 index 000000000..4b26be89e --- /dev/null +++ b/client/src/protoFleet/features/auth/components/AuthenticateMiners/AuthenticateMiners.test.tsx @@ -0,0 +1,672 @@ +import { fireEvent, render } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import AuthenticateMiners from "./AuthenticateMiners"; +import { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import useAuthNeededMiners from "@/protoFleet/api/useAuthNeededMiners"; +import useFleet from "@/protoFleet/api/useFleet"; +import { useMinerPairing } from "@/protoFleet/api/useMinerPairing"; +import { useOnboardedStatus } from "@/protoFleet/api/useOnboardedStatus"; + +vi.mock("@/protoFleet/api/useAuthNeededMiners"); +vi.mock("@/protoFleet/api/useFleet"); +vi.mock("@/protoFleet/api/useMinerPairing"); +vi.mock("@/protoFleet/api/useOnboardedStatus"); +vi.mock("@/shared/features/toaster"); + +const mockRefetchMiners = vi.fn(); +const mockNotifyPairingCompleted = vi.fn(); + +const mockUnpairedMiners = { + miner1: { + deviceIdentifier: "miner1", + macAddress: "00:00:00:00:00:01", + model: "Proto Rig", + name: "Miner 1", + ipAddress: "192.168.1.101", + }, + miner2: { + deviceIdentifier: "miner2", + macAddress: "00:00:00:00:00:02", + model: "Proto Rig", + name: "Miner 2", + ipAddress: "192.168.1.102", + }, + miner3: { + deviceIdentifier: "miner3", + macAddress: "00:00:00:00:00:03", + model: "Proto Rig", + name: "Miner 3", + ipAddress: "192.168.1.103", + }, +} as unknown as Record; + +const mockOnClose = vi.fn(); +const mockPair = vi.fn(); +const mockRefetchOnboardingStatus = vi.fn(); +const mockRefetchFleet = vi.fn(); +const mockOnSuccess = vi.fn(); + +beforeEach(() => { + vi.mocked(useAuthNeededMiners).mockReturnValue({ + minerIds: ["miner1", "miner2", "miner3"], + miners: mockUnpairedMiners, + totalMiners: 3, + hasMore: false, + isLoading: false, + hasInitialLoadCompleted: true, + loadMore: vi.fn(), + refetch: vi.fn(), + availableModels: [], + }); + + vi.mocked(useMinerPairing).mockReturnValue({ + discover: vi.fn(), + pair: mockPair, + discoverPending: false, + pairingPending: false, + }); + + vi.mocked(useOnboardedStatus).mockReturnValue({ + poolConfigured: false, + devicePaired: true, + statusLoaded: true, + refetch: mockRefetchOnboardingStatus, + }); + + vi.mocked(useFleet).mockReturnValue({ + minerIds: [], + miners: {}, + totalMiners: 0, + hasMore: false, + isLoading: false, + hasInitialLoadCompleted: true, + loadMore: vi.fn(), + currentPage: 0, + hasPreviousPage: false, + goToNextPage: vi.fn(), + goToPrevPage: vi.fn(), + refetch: mockRefetchFleet, + refreshCurrentPage: vi.fn(), + updateMinerWorkerName: vi.fn(), + availableModels: [], + }); + + vi.clearAllMocks(); +}); + +describe("AuthenticateMiners", () => { + const showMinersLabel = "Show miners"; + const bulkUsernameLabel = "Miner username"; + const bulkPasswordLabel = "Miner password"; + const usernameLabel = "Username"; + const passwordLabel = "Password"; + + const mockUsername = "admin"; + const mockPassword = "test1234"; + + it("renders with all miners selected by default", () => { + const { getByText } = render( + , + ); + + fireEvent.click(getByText(showMinersLabel)); + + expect(getByText("3 miners selected")).toBeInTheDocument(); + }); + + it("toggles between showing and hiding miner list", () => { + const { getByText, queryByText } = render( + , + ); + + expect(queryByText("IP Address")).not.toBeInTheDocument(); + + fireEvent.click(getByText(showMinersLabel)); + expect(getByText("IP Address")).toBeInTheDocument(); + + fireEvent.click(getByText("Hide miner list")); + expect(queryByText("IP Address")).not.toBeInTheDocument(); + }); + + it("allows entering bulk credentials", async () => { + const { getByLabelText } = render( + , + ); + + const usernameInput = getByLabelText(bulkUsernameLabel); + const passwordInput = getByLabelText(bulkPasswordLabel); + + fireEvent.change(usernameInput, { target: { value: mockUsername } }); + fireEvent.change(passwordInput, { target: { value: mockPassword } }); + + expect(usernameInput).toHaveValue(mockUsername); + expect(passwordInput).toHaveValue(mockPassword); + }); + + it("autofocuses the bulk username input on mount", () => { + const { getByLabelText } = render( + , + ); + + const usernameInput = getByLabelText(bulkUsernameLabel); + expect(usernameInput).toHaveFocus(); + }); + + it("shows error when authenticating without credentials", () => { + const { getByText } = render( + , + ); + + fireEvent.click(getByText("Authenticate")); + + expect(getByText("Enter a username and password and try again.")).toBeInTheDocument(); + }); + + it("shows individual credential inputs for each miner", async () => { + const { getByText, getAllByLabelText } = render( + , + ); + + fireEvent.click(getByText(showMinersLabel)); + + const usernameInputs = getAllByLabelText(usernameLabel); + const passwordInputs = getAllByLabelText(passwordLabel); + + expect(usernameInputs).toHaveLength(Object.keys(mockUnpairedMiners).length); + expect(passwordInputs).toHaveLength(Object.keys(mockUnpairedMiners).length); + }); + + it("populates individual miner inputs with bulk credentials", async () => { + const { getByText, getByLabelText, getAllByLabelText } = render( + , + ); + + fireEvent.change(getByLabelText(bulkUsernameLabel), { + target: { value: mockUsername }, + }); + fireEvent.change(getByLabelText(bulkPasswordLabel), { + target: { value: mockPassword }, + }); + + fireEvent.click(getByText(showMinersLabel)); + + await vi.waitFor(() => { + const usernameInputs = getAllByLabelText(usernameLabel); + const passwordInputs = getAllByLabelText(passwordLabel); + + usernameInputs.forEach((input) => { + expect(input).toHaveValue(mockUsername); + }); + passwordInputs.forEach((input) => { + expect(input).toHaveValue(mockPassword); + }); + }); + }); + + it("toggles password visibility", async () => { + const { getByText, getByLabelText, getAllByLabelText } = render( + , + ); + + fireEvent.click(getByText(showMinersLabel)); + + const passwordInputs = getAllByLabelText(passwordLabel); + passwordInputs.forEach((input) => { + expect(input).toHaveAttribute("type", "password"); + }); + + fireEvent.click(getByLabelText("Show passwords")); + + passwordInputs.forEach((input) => { + expect(input).toHaveAttribute("type", "text"); + }); + }); + + it("allows selecting and deselecting all miners", () => { + const { getByText } = render( + , + ); + + fireEvent.click(getByText(showMinersLabel)); + + fireEvent.click(getByText("Select none")); + expect(getByText("0 miners selected")).toBeInTheDocument(); + + fireEvent.click(getByText("Select all")); + expect(getByText("3 miners selected")).toBeInTheDocument(); + }); + + it("filters miners by model", async () => { + const { getByText, getAllByText } = render( + , + ); + + fireEvent.click(getByText(showMinersLabel)); + + // Find the Model dropdown filter button (not the table header) + const modelButtons = getAllByText("Model"); + // The dropdown filter button should be the first one + const modelDropdown = modelButtons[0].closest("button"); + expect(modelDropdown).toBeInTheDocument(); + + fireEvent.click(modelDropdown!); + + // Check that Proto Rig option appears (could be multiple - in dropdown and in table) + const protoRigOptions = getAllByText("Proto Rig"); + expect(protoRigOptions.length).toBeGreaterThan(0); + }); + + it("disables inputs during authentication", async () => { + const { getByText, getByLabelText } = render( + , + ); + + fireEvent.change(getByLabelText(bulkUsernameLabel), { + target: { value: mockUsername }, + }); + fireEvent.change(getByLabelText(bulkPasswordLabel), { + target: { value: mockPassword }, + }); + + expect(getByLabelText(bulkUsernameLabel)).not.toBeDisabled(); + expect(getByLabelText(bulkPasswordLabel)).not.toBeDisabled(); + + fireEvent.click(getByText("Authenticate")); + + expect(getByLabelText(bulkUsernameLabel)).toBeDisabled(); + expect(getByLabelText(bulkPasswordLabel)).toBeDisabled(); + }); + + it("clears individual credentials when toggling miner list", async () => { + const { getByText, getAllByLabelText } = render( + , + ); + + fireEvent.click(getByText(showMinersLabel)); + + const firstUsernameInput = getAllByLabelText(usernameLabel)[0]; + + fireEvent.change(firstUsernameInput, { + target: { value: "customuser" }, + }); + + fireEvent.click(getByText("Hide miner list")); + fireEvent.click(getByText(showMinersLabel)); + + const usernameInputs = getAllByLabelText(usernameLabel); + expect(usernameInputs[0]).not.toHaveValue("customuser"); + }); + + it("calls pair API with bulk credentials when authenticate is clicked", async () => { + const { getByText, getByLabelText } = render( + , + ); + + fireEvent.change(getByLabelText(bulkUsernameLabel), { + target: { value: mockUsername }, + }); + fireEvent.change(getByLabelText(bulkPasswordLabel), { + target: { value: mockPassword }, + }); + + fireEvent.click(getByText("Authenticate")); + + expect(mockPair).toHaveBeenCalledTimes(1); + // Bulk mode uses allDevices selector with AUTHENTICATION_NEEDED pairing status filter + expect(mockPair).toHaveBeenCalledWith( + expect.objectContaining({ + pairRequest: expect.objectContaining({ + credentials: expect.objectContaining({ + username: mockUsername, + password: mockPassword, + }), + deviceSelector: expect.objectContaining({ + selectionType: expect.objectContaining({ + case: "allDevices", + }), + }), + }), + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ); + }); + + it("groups miners with same credentials into single pair request", async () => { + const { getByText, getByLabelText, getAllByLabelText } = render( + , + ); + + fireEvent.change(getByLabelText(bulkUsernameLabel), { + target: { value: "bulk-user" }, + }); + fireEvent.change(getByLabelText(bulkPasswordLabel), { + target: { value: "bulk-pass" }, + }); + + fireEvent.click(getByText(showMinersLabel)); + const usernameInputs = getAllByLabelText(usernameLabel); + const passwordInputs = getAllByLabelText(passwordLabel); + + fireEvent.change(usernameInputs[0], { + target: { value: "custom-user" }, + }); + fireEvent.change(passwordInputs[0], { + target: { value: "custom-pass" }, + }); + + fireEvent.click(getByText("Authenticate")); + + // Should make 2 pair requests: one for custom credentials, one for bulk + expect(mockPair).toHaveBeenCalledTimes(2); + }); + + it("calls refetch after successful authentication", async () => { + const { getByText, getByLabelText } = render( + , + ); + + mockPair.mockImplementation(({ onSuccess }) => { + onSuccess([]); + }); + + fireEvent.change(getByLabelText(bulkUsernameLabel), { + target: { value: mockUsername }, + }); + fireEvent.change(getByLabelText(bulkPasswordLabel), { + target: { value: mockPassword }, + }); + + fireEvent.click(getByText("Authenticate")); + + await vi.waitFor(() => { + expect(mockRefetchOnboardingStatus).toHaveBeenCalled(); + expect(mockRefetchMiners).toHaveBeenCalled(); + expect(mockNotifyPairingCompleted).toHaveBeenCalled(); + expect(mockOnSuccess).toHaveBeenCalled(); + }); + }); + + it("displays correct total devices count", () => { + const { getByText } = render( + , + ); + + expect(getByText("3 miners remaining")).toBeInTheDocument(); + }); + + it("disables authenticate button when no miners are selected", () => { + const { getByText } = render( + , + ); + + fireEvent.click(getByText(showMinersLabel)); + fireEvent.click(getByText("Select none")); + + const authenticateButton = getByText("Authenticate").closest("button"); + expect(authenticateButton).toBeDisabled(); + }); + + it("enables authenticate button when miners are selected", () => { + const { getByText } = render( + , + ); + + fireEvent.click(getByText(showMinersLabel)); + + // By default all miners are selected + const authenticateButton = getByText("Authenticate").closest("button"); + expect(authenticateButton).not.toBeDisabled(); + }); + + it("re-enables authenticate button after selecting miners", () => { + const { getByText } = render( + , + ); + + fireEvent.click(getByText(showMinersLabel)); + + // Deselect all + fireEvent.click(getByText("Select none")); + let authenticateButton = getByText("Authenticate").closest("button"); + expect(authenticateButton).toBeDisabled(); + + // Select all again + fireEvent.click(getByText("Select all")); + authenticateButton = getByText("Authenticate").closest("button"); + expect(authenticateButton).not.toBeDisabled(); + }); + + describe("selection persistence", () => { + it("preserves empty selection when user deselects all miners", async () => { + const { getByText } = render( + , + ); + + fireEvent.click(getByText(showMinersLabel)); + + // Initially all miners selected + expect(getByText("3 miners selected")).toBeInTheDocument(); + + // User deselects all + fireEvent.click(getByText("Select none")); + expect(getByText("0 miners selected")).toBeInTheDocument(); + + // Selection should remain empty (not auto-select all again) + await vi.waitFor( + () => { + expect(getByText("0 miners selected")).toBeInTheDocument(); + }, + { timeout: 500 }, + ); + }); + + it("does not reset selection to all when miner list updates", async () => { + const mockRefetch = vi.fn(); + const { getByText, rerender } = render( + , + ); + + fireEvent.click(getByText(showMinersLabel)); + + // Initially all 3 miners selected + expect(getByText("3 miners selected")).toBeInTheDocument(); + + // User deselects all + fireEvent.click(getByText("Select none")); + expect(getByText("0 miners selected")).toBeInTheDocument(); + + // Simulate miner list update (e.g., after authentication removes some miners) + const remainingMiners = { + miner3: mockUnpairedMiners.miner3, + }; + + vi.mocked(useAuthNeededMiners).mockReturnValue({ + minerIds: ["miner3"], + miners: remainingMiners, + totalMiners: 1, + hasMore: false, + isLoading: false, + hasInitialLoadCompleted: true, + availableModels: [], + loadMore: vi.fn(), + refetch: mockRefetch, + }); + + // Trigger re-render with updated miner list + rerender( + , + ); + + // Selection should NOT reset to "all miners" - should remain empty + // Before the fix, this would show "1 miner selected" + await vi.waitFor(() => { + const selectionText = getByText(/miners selected/); + expect(selectionText.textContent).toBe("0 miners selected"); + }); + }); + + it("initializes with all miners selected on first load", () => { + vi.mocked(useAuthNeededMiners).mockReturnValue({ + minerIds: [], + miners: {}, + totalMiners: 0, + hasMore: false, + isLoading: true, + hasInitialLoadCompleted: false, + availableModels: [], + loadMore: vi.fn(), + refetch: vi.fn(), + }); + + const { getByText, rerender } = render( + , + ); + + // No miners loaded yet - should show 0 + expect(getByText("0 miners remaining")).toBeInTheDocument(); + + // Miners load + vi.mocked(useAuthNeededMiners).mockReturnValue({ + minerIds: ["miner1", "miner2", "miner3"], + miners: mockUnpairedMiners, + totalMiners: 3, + hasMore: false, + isLoading: false, + hasInitialLoadCompleted: true, + availableModels: [], + loadMore: vi.fn(), + refetch: vi.fn(), + }); + + rerender( + , + ); + + fireEvent.click(getByText(showMinersLabel)); + + // Should auto-select all miners on initial load + expect(getByText("3 miners selected")).toBeInTheDocument(); + }); + }); +}); diff --git a/client/src/protoFleet/features/auth/components/AuthenticateMiners/AuthenticateMiners.tsx b/client/src/protoFleet/features/auth/components/AuthenticateMiners/AuthenticateMiners.tsx new file mode 100644 index 000000000..4c59e648f --- /dev/null +++ b/client/src/protoFleet/features/auth/components/AuthenticateMiners/AuthenticateMiners.tsx @@ -0,0 +1,549 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { create } from "@bufbuild/protobuf"; +import { DeviceIdentifierListSchema } from "@/protoFleet/api/generated/common/v1/device_selector_pb"; +import { PairingStatus } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { DeviceFilterSchema, DeviceSelectorSchema } from "@/protoFleet/api/generated/minercommand/v1/command_pb"; +import { CredentialsSchema, PairRequestSchema } from "@/protoFleet/api/generated/pairing/v1/pairing_pb"; +import useAuthNeededMiners from "@/protoFleet/api/useAuthNeededMiners"; +import { useMinerPairing } from "@/protoFleet/api/useMinerPairing"; +import { useOnboardedStatus } from "@/protoFleet/api/useOnboardedStatus"; +import { ids } from "@/protoFleet/features/auth/components/AuthenticateMiners/constants"; +import { Credentials, UnauthenticatedMiner } from "@/protoFleet/features/auth/components/AuthenticateMiners/types"; +import { createModelFilter, filterByModel } from "@/protoFleet/utils/minerFilters"; +import { Alert } from "@/shared/assets/icons"; +import { sizes, variants } from "@/shared/components/Button/constants"; +import Callout, { intents } from "@/shared/components/Callout"; +import Input from "@/shared/components/Input"; +import List from "@/shared/components/List"; +import { ActiveFilters } from "@/shared/components/List/Filters/types"; +import Modal, { ModalSelectAllFooter } from "@/shared/components/Modal"; + +import Switch from "@/shared/components/Switch"; +import { pushToast, STATUSES as TOAST_STATUSES } from "@/shared/features/toaster"; + +const activeCols = ["model", "ipAddress", "username", "password"] as (keyof UnauthenticatedMiner)[]; + +const colTitles = { + model: "Model", + deviceIdentifier: "ID", + macAddress: "MAC Address", + ipAddress: "IP Address", + username: "Username", + password: "Password", +} as { + [key in (typeof activeCols)[number]]: string; +}; + +type AuthenticateMinersProps = { + open?: boolean; + onClose: () => void; + onSuccess?: () => void; + onPairingCompleted?: () => void; + onRefetchMiners?: () => void; +}; + +const AuthenticateMiners = ({ + open, + onClose, + onSuccess, + onPairingCompleted, + onRefetchMiners, +}: AuthenticateMinersProps) => { + const isVisible = open ?? true; + // Component fetches its own data + const { + miners: minersByIdentifier, + refetch: refetchAuthNeededMiners, + totalMiners, + } = useAuthNeededMiners({ + enabled: isVisible, + }); + const { pair } = useMinerPairing(); + const { refetch: refetchOnboardingStatus } = useOnboardedStatus({ enabled: isVisible }); + + // Track if component is mounted to prevent state updates after unmount + const isMountedRef = useRef(true); + + // Stable reference to track authentication completion across re-renders + const completionTrackerRef = useRef<{ + completed: number; + total: number; + failedMiners: string[]; + }>({ + completed: 0, + total: 0, + failedMiners: [], + }); + + useEffect(() => { + return () => { + isMountedRef.current = false; + }; + }, []); + + useEffect(() => { + if (!isVisible) { + setBulkCredentials({ username: "", password: "" }); + setCredentials({}); + setHasMissingCredentials(false); + setMinerErrors([]); + setAuthenticateLoading(false); + setShowMiners(false); + setShowPasswords(false); + hasInitializedSelectionRef.current = false; + } + }, [isVisible]); + + const [bulkCredentials, setBulkCredentials] = useState({ + username: "", + password: "", + }); + // stores credentials for each miner, keyed by deviceIdentifier + const [credentials, setCredentials] = useState>({}); + const [hasMissingCredentials, setHasMissingCredentials] = useState(false); + // stores ids of miners that have errors + const [minerErrors, setMinerErrors] = useState([]); + const [authenticateLoading, setAuthenticateLoading] = useState(false); + + const errorMessage = useMemo(() => { + if (hasMissingCredentials) { + return "Enter a username and password and try again."; + } + if (minerErrors && minerErrors.length > 0) { + return "Try your username and password again."; + } + return null; + }, [hasMissingCredentials, minerErrors]); + + const handleBulkChange = useCallback( + (value: string, id: string) => { + setBulkCredentials({ ...bulkCredentials, [id]: value.trim() }); + }, + [bulkCredentials], + ); + + const handleMinerChange = useCallback( + (deviceIdentifier: string, key: string, value: string) => { + const newValue = { ...credentials }; + newValue[deviceIdentifier] = { + ...(credentials[deviceIdentifier] || {}), + [key]: value.trim(), + }; + setCredentials(newValue); + }, + [credentials], + ); + + const [showMiners, setShowMiners] = useState(false); + const [showPasswords, setShowPasswords] = useState(false); + + const minerItems: UnauthenticatedMiner[] = useMemo(() => { + return Object.values(minersByIdentifier).map((device) => ({ + deviceIdentifier: device.deviceIdentifier, + model: device.model, + macAddress: device.macAddress || "", + ipAddress: device.ipAddress || "", + username: "", + password: "", + })); + }, [minersByIdentifier]); + + const [selectedMiners, setSelectedMiners] = useState([]); + // Track if we've initialized selection to prevent unwanted resets + const hasInitializedSelectionRef = useRef(false); + const [activeFilters, setActiveFilters] = useState({ + buttonFilters: [], + dropdownFilters: {}, + }); + + // Initialize selection to all miners only on first data load + // After initial load, preserve user selection even when miner list updates + useEffect(() => { + const minerIds = Object.keys(minersByIdentifier); + if (!hasInitializedSelectionRef.current && minerIds.length > 0) { + setSelectedMiners(minerIds); + hasInitializedSelectionRef.current = true; + } + }, [minersByIdentifier]); + + const models = useMemo(() => { + return Array.from(new Set(minerItems.map((miner) => miner.model))); + }, [minerItems]); + + const modelFilter = useMemo(() => createModelFilter(models), [models]); + + const filters = useMemo(() => [modelFilter], [modelFilter]); + + const filteredMiners = useMemo(() => { + return minerItems.filter((miner) => filterByModel(miner, activeFilters)); + }, [minerItems, activeFilters]); + + const colConfig = useMemo(() => { + return { + model: { + width: "w-40", + }, + macAddress: { + width: "w-40", + }, + username: { + component: (item: UnauthenticatedMiner) => ( + id === item.deviceIdentifier) !== undefined} + onChange={handleMinerChange.bind(this, item.deviceIdentifier, ids.username)} + /> + ), + width: "w-70 !py-3", + }, + password: { + component: (item: UnauthenticatedMiner) => ( + id === item.deviceIdentifier) !== undefined} + onChange={handleMinerChange.bind(this, item.deviceIdentifier, ids.password)} + /> + ), + width: "w-70 !py-3", + }, + }; + }, [handleMinerChange, bulkCredentials, showPasswords, authenticateLoading, minerErrors, credentials]); + + // Helper to perform common post-authentication operations + const handleAuthenticationComplete = useCallback( + (successCount: number) => { + refetchOnboardingStatus(); + onRefetchMiners?.(); + refetchAuthNeededMiners(); + onPairingCompleted?.(); + if (successCount > 0) { + onSuccess?.(); + } + }, + [refetchOnboardingStatus, onRefetchMiners, refetchAuthNeededMiners, onPairingCompleted, onSuccess], + ); + + const authenticateMiners = useCallback(() => { + if ( + (bulkCredentials.username === "" || bulkCredentials.password === "") && + Object.entries(credentials).length === 0 + ) { + setHasMissingCredentials(true); + return; + } + + setHasMissingCredentials(false); + setAuthenticateLoading(true); + + // Determine if we can use bulk mode (all miners with same credentials) + const hasIndividualCredentials = Object.keys(credentials).length > 0; + const useBulkMode = + !showMiners && !hasIndividualCredentials && bulkCredentials.username && bulkCredentials.password; + + if (useBulkMode) { + // Bulk mode: Use all_devices selector with AUTHENTICATION_NEEDED filter + // This allows authenticating all auth-needed miners without pagination limits + const pairRequest = create(PairRequestSchema, { + credentials: create(CredentialsSchema, { + username: bulkCredentials.username, + password: bulkCredentials.password, + }), + deviceSelector: create(DeviceSelectorSchema, { + selectionType: { + case: "allDevices", + value: create(DeviceFilterSchema, { + pairingStatus: [PairingStatus.AUTHENTICATION_NEEDED], + }), + }, + }), + }); + + pair({ + pairRequest, + onSuccess: (failedDeviceIds) => { + if (!isMountedRef.current) return; + + setAuthenticateLoading(false); + setMinerErrors(failedDeviceIds); + + const successCount = totalMiners - failedDeviceIds.length; + const allSucceeded = failedDeviceIds.length === 0; + const allFailed = failedDeviceIds.length === totalMiners; + + if (allSucceeded) { + pushToast({ + message: "All miners authenticated.", + status: TOAST_STATUSES.success, + }); + onClose(); + } else if (allFailed) { + pushToast({ + message: "Authentication failed. Please check your credentials and try again.", + status: TOAST_STATUSES.error, + }); + } else { + pushToast({ + message: `You authenticated ${successCount} of ${totalMiners} miners.`, + status: TOAST_STATUSES.error, + }); + } + + handleAuthenticationComplete(successCount); + }, + onError: (error) => { + if (!isMountedRef.current) return; + + console.error("Pairing error:", error); + setAuthenticateLoading(false); + pushToast({ + message: "Authentication failed. Please check your credentials and try again.", + status: TOAST_STATUSES.error, + }); + }, + }); + return; + } + + // Individual mode: Group selected miners by their credentials + // Uses include_devices selector with explicit device identifiers + const credentialGroups = new Map(); + + selectedMiners.forEach((deviceId) => { + const minerCreds = credentials[deviceId] || bulkCredentials; + const key = `${minerCreds.username}|||${minerCreds.password}`; + + const existing = credentialGroups.get(key); + if (existing) { + existing.deviceIds.push(deviceId); + } else { + credentialGroups.set(key, { + creds: minerCreds, + deviceIds: [deviceId], + }); + } + }); + + // Initialize or reset the completion tracker + completionTrackerRef.current = { + completed: 0, + total: credentialGroups.size, + failedMiners: [] as string[], + }; + + const handleRequestComplete = () => { + completionTrackerRef.current.completed++; + + // Only process final results if all requests are complete + if (completionTrackerRef.current.completed !== completionTrackerRef.current.total) return; + + // Check if component is still mounted before updating state + if (!isMountedRef.current) return; + + setAuthenticateLoading(false); + setMinerErrors(completionTrackerRef.current.failedMiners); + + const successCount = selectedMiners.length - completionTrackerRef.current.failedMiners.length; + const allSucceeded = completionTrackerRef.current.failedMiners.length === 0; + const allFailed = completionTrackerRef.current.failedMiners.length === selectedMiners.length; + const loadedMinersCount = Object.keys(minersByIdentifier).length; + const allMinersAuthenticated = allSucceeded && successCount === loadedMinersCount; + + if (allMinersAuthenticated) { + pushToast({ + message: "All miners authenticated.", + status: TOAST_STATUSES.success, + }); + // Close modal after all miners in the list are successfully authenticated + onClose(); + } else if (allSucceeded) { + pushToast({ + message: `${successCount} ${successCount === 1 ? "miner" : "miners"} authenticated.`, + status: TOAST_STATUSES.success, + }); + } else if (allFailed) { + pushToast({ + message: "Authentication failed. Please check your credentials and try again.", + status: TOAST_STATUSES.error, + }); + } else { + pushToast({ + message: `You authenticated ${successCount} of ${selectedMiners.length} miners.`, + status: TOAST_STATUSES.error, + }); + } + + handleAuthenticationComplete(successCount); + }; + + // Make a pair request for each credential group using include_devices selector + credentialGroups.forEach(({ creds, deviceIds }) => { + const pairRequest = create(PairRequestSchema, { + credentials: create(CredentialsSchema, { + username: creds.username, + password: creds.password, + }), + deviceSelector: create(DeviceSelectorSchema, { + selectionType: { + case: "includeDevices", + value: create(DeviceIdentifierListSchema, { + deviceIdentifiers: deviceIds, + }), + }, + }), + }); + + pair({ + pairRequest, + onSuccess: (failedDeviceIds) => { + // Safely aggregate failed device IDs + completionTrackerRef.current.failedMiners.push(...failedDeviceIds); + handleRequestComplete(); + }, + onError: (error) => { + console.error("Pairing error:", error); + // On error, mark all devices in this group as failed + completionTrackerRef.current.failedMiners.push(...deviceIds); + handleRequestComplete(); + }, + }); + }); + }, [ + bulkCredentials, + credentials, + selectedMiners, + minersByIdentifier, + showMiners, + totalMiners, + handleAuthenticationComplete, + onClose, + pair, + ]); + + return ( + { + setCredentials({}); + setMinerErrors([]); + setShowMiners((prev) => !prev); + }, + }, + { + variant: variants.primary, + text: "Authenticate", + dismissModalOnClick: false, + loading: authenticateLoading, + disabled: selectedMiners.length === 0, + onClick: authenticateMiners, + }, + ]} + size={showMiners ? "large" : undefined} + title="Authenticate miners" + description={ + !showMiners + ? "If miners use different credentials, we'll try each attempt until all miners are configured." + : undefined + } + > + {errorMessage !== null && ( + } + title={errorMessage} + dismissible + onDismiss={() => { + setHasMissingCredentials(false); + setMinerErrors([]); + }} + /> + )} +
+
+
+
Bulk authenticate
+
+ {totalMiners} {totalMiners === 1 ? "miner" : "miners"} remaining +
+
+ + +
+
+ {showMiners && ( + <> +
+ + filters={filters} + filterItem={filterByModel} + onFilterChange={setActiveFilters} + filterSize={sizes.compact} + headerControls={} + activeCols={activeCols} + colTitles={colTitles} + colConfig={colConfig} + items={minerItems} + itemKey="deviceIdentifier" + itemSelectable + customSelectedItems={selectedMiners} + customSetSelectedItems={setSelectedMiners} + containerClassName="max-h-[50vh]" + stickyBgColor="bg-surface-elevated-base" + /> +
+ setSelectedMiners(filteredMiners.map((miner) => miner.deviceIdentifier))} + onSelectNone={() => setSelectedMiners([])} + /> + + )} +
+ ); +}; + +export default AuthenticateMiners; diff --git a/client/src/protoFleet/features/auth/components/AuthenticateMiners/constants.ts b/client/src/protoFleet/features/auth/components/AuthenticateMiners/constants.ts new file mode 100644 index 000000000..ea785e604 --- /dev/null +++ b/client/src/protoFleet/features/auth/components/AuthenticateMiners/constants.ts @@ -0,0 +1,4 @@ +export const ids = { + username: "username", + password: "password", +}; diff --git a/client/src/protoFleet/features/auth/components/AuthenticateMiners/index.ts b/client/src/protoFleet/features/auth/components/AuthenticateMiners/index.ts new file mode 100644 index 000000000..6da2d460e --- /dev/null +++ b/client/src/protoFleet/features/auth/components/AuthenticateMiners/index.ts @@ -0,0 +1,3 @@ +import AuthenticateMiners from "./AuthenticateMiners"; + +export { AuthenticateMiners }; diff --git a/client/src/protoFleet/features/auth/components/AuthenticateMiners/types.ts b/client/src/protoFleet/features/auth/components/AuthenticateMiners/types.ts new file mode 100644 index 000000000..c0a6bd59a --- /dev/null +++ b/client/src/protoFleet/features/auth/components/AuthenticateMiners/types.ts @@ -0,0 +1,15 @@ +// UnauthenticatedMiner represents the data structure used in the authentication flow +// It contains basic miner info along with credential fields +export type UnauthenticatedMiner = { + deviceIdentifier: string; + model: string; + macAddress: string; + ipAddress: string; + username: string; + password: string; +}; + +export type Credentials = { + username: string; + password: string; +}; diff --git a/client/src/protoFleet/features/auth/components/LoginModal/LoginForm.tsx b/client/src/protoFleet/features/auth/components/LoginModal/LoginForm.tsx new file mode 100644 index 000000000..a8f7c4138 --- /dev/null +++ b/client/src/protoFleet/features/auth/components/LoginModal/LoginForm.tsx @@ -0,0 +1,142 @@ +import { useCallback, useState } from "react"; +import clsx from "clsx"; + +import { create } from "@bufbuild/protobuf"; +import { AuthenticateRequestSchema } from "@/protoFleet/api/generated/auth/v1/auth_pb"; +import { useLogin } from "@/protoFleet/api/useLogin"; +import { ids, initValues, type Values } from "@/protoFleet/features/auth/components/LoginModal"; +import { useSetTemporaryPassword } from "@/protoFleet/store"; + +import { Alert, Logo } from "@/shared/assets/icons"; +import { variants } from "@/shared/components/Button"; +import ButtonGroup, { ButtonProps, groupVariants, sizes } from "@/shared/components/ButtonGroup"; +import Callout from "@/shared/components/Callout"; +import Input from "@/shared/components/Input"; +import { useKeyDown } from "@/shared/hooks/useKeyDown"; + +import { deepClone } from "@/shared/utils/utility"; + +interface LoginFormProps { + onDismiss?: () => void; + onSuccess: (requiresPasswordChange: boolean) => void; +} + +const LoginForm = ({ onDismiss, onSuccess }: LoginFormProps) => { + const [values, setValues] = useState(deepClone(initValues)); + const [errors, setErrors] = useState(deepClone(initValues)); + const [apiError, setApiError] = useState(null); + const login = useLogin(); + const [isSubmitting, setIsSubmitting] = useState(false); + const setTemporaryPassword = useSetTemporaryPassword(); + + const handleChange = useCallback( + (value: string, id: string) => { + setValues({ ...values, [id]: value.trim() }); + // clear errors if the user starts typing + setErrors(deepClone(initValues)); + setApiError(null); + }, + [values], + ); + + const handleContinue = useCallback(() => { + setIsSubmitting(true); + login({ + loginRequest: create(AuthenticateRequestSchema, { + username: values.username, + password: values.password, + }), + onSuccess: (requiresPasswordChange: boolean) => { + if (requiresPasswordChange) { + setTemporaryPassword(values.password); + } + onSuccess(requiresPasswordChange); + }, + onError: () => setApiError("Invalid credentials entered."), + onFinally: () => setIsSubmitting(false), + }); + }, [login, values.username, values.password, onSuccess, setTemporaryPassword]); + + const handleEnter = useCallback(() => { + if (isSubmitting) { + return; + } + + handleContinue(); + }, [isSubmitting, handleContinue]); + + useKeyDown({ key: "Enter", onKeyDown: handleEnter }); + + return ( +
+ +
+
+
+
Log in
+
+ +
+
+ } title="Invalid credentials entered." /> +
+ + + + +
+
+ + !!button.text) as ButtonProps[] + } + /> +
+
+
Powerful mining tools. Built for decentralization.
+
© {new Date().getFullYear()} Block, Inc.
+
+
+ ); +}; + +export default LoginForm; diff --git a/client/src/protoFleet/features/auth/components/LoginModal/constants.ts b/client/src/protoFleet/features/auth/components/LoginModal/constants.ts new file mode 100644 index 000000000..2868d9db3 --- /dev/null +++ b/client/src/protoFleet/features/auth/components/LoginModal/constants.ts @@ -0,0 +1,11 @@ +export const ids = { + username: "username", + password: "password", + confirmPassword: "confirmPassword", +}; + +export const initValues = { + username: "", + password: "", + confirmPassword: "", +}; diff --git a/client/src/protoFleet/features/auth/components/LoginModal/index.ts b/client/src/protoFleet/features/auth/components/LoginModal/index.ts new file mode 100644 index 000000000..8cc08fd5a --- /dev/null +++ b/client/src/protoFleet/features/auth/components/LoginModal/index.ts @@ -0,0 +1,4 @@ +import { ids, initValues } from "./constants"; +import { Values } from "./types"; + +export { ids, initValues, type Values }; diff --git a/client/src/protoFleet/features/auth/components/LoginModal/types.ts b/client/src/protoFleet/features/auth/components/LoginModal/types.ts new file mode 100644 index 000000000..35ab90184 --- /dev/null +++ b/client/src/protoFleet/features/auth/components/LoginModal/types.ts @@ -0,0 +1,5 @@ +export interface Values { + username: string; + password: string; + confirmPassword: string; +} diff --git a/client/src/protoFleet/features/auth/components/UpdatePasswordForm.stories.tsx b/client/src/protoFleet/features/auth/components/UpdatePasswordForm.stories.tsx new file mode 100644 index 000000000..4dbadddfd --- /dev/null +++ b/client/src/protoFleet/features/auth/components/UpdatePasswordForm.stories.tsx @@ -0,0 +1,126 @@ +import { useState } from "react"; +import { action } from "storybook/actions"; +import { UpdatePasswordForm } from "./UpdatePasswordForm"; + +export default { + title: "Proto Fleet/Auth/UpdatePasswordForm", + component: UpdatePasswordForm, +}; + +// Default story +export const Default = () => { + return ( + { + action("onSubmit")(newPassword, confirmPassword); + }} + isSubmitting={false} + errorMsg="" + /> + ); +}; + +// With error message +export const WithError = () => { + return ( + { + action("onSubmit")(newPassword, confirmPassword); + }} + isSubmitting={false} + errorMsg="Passwords do not match. Please try again." + /> + ); +}; + +// With validation error +export const WithWeakPasswordError = () => { + return ( + { + action("onSubmit")(newPassword, confirmPassword); + }} + isSubmitting={false} + errorMsg="Password must be at least 8 characters and include uppercase, lowercase, number, and special character." + /> + ); +}; + +// Loading state +export const LoadingState = () => { + return ( + { + action("onSubmit")(newPassword, confirmPassword); + }} + isSubmitting={true} + errorMsg="" + /> + ); +}; + +// Interactive demo +export const Interactive = () => { + const [isSubmitting, setIsSubmitting] = useState(false); + const [errorMsg, setErrorMsg] = useState(""); + + const handleSubmit = (newPassword: string, confirmPassword: string) => { + action("onSubmit")(newPassword, confirmPassword); + setErrorMsg(""); + + // Validate passwords match + if (newPassword !== confirmPassword) { + setErrorMsg("Passwords do not match. Please try again."); + return; + } + + // Validate password strength (basic check) + if (newPassword.length < 8) { + setErrorMsg("Password must be at least 8 characters long."); + return; + } + + // Simulate API call + setIsSubmitting(true); + setTimeout(() => { + setIsSubmitting(false); + action("Success!")(); + }, 2000); + }; + + return ( +
+
+ Try entering mismatched passwords or a weak password to see validation errors. Enter matching strong passwords + to simulate success (2 second delay). +
+ setErrorMsg("")} + /> +
+ ); +}; + +// With strong password +export const WithStrongPassword = () => { + const [errorMsg, setErrorMsg] = useState(""); + + return ( +
+
+ Try entering: "StrongP@ssw0rd" to see a strong password score +
+ { + action("onSubmit")(newPassword, confirmPassword); + }} + isSubmitting={false} + errorMsg={errorMsg} + onErrorDismiss={() => setErrorMsg("")} + /> +
+ ); +}; diff --git a/client/src/protoFleet/features/auth/components/UpdatePasswordForm.test.tsx b/client/src/protoFleet/features/auth/components/UpdatePasswordForm.test.tsx new file mode 100644 index 000000000..9b4860797 --- /dev/null +++ b/client/src/protoFleet/features/auth/components/UpdatePasswordForm.test.tsx @@ -0,0 +1,173 @@ +import { fireEvent, render } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { UpdatePasswordForm } from "./UpdatePasswordForm"; + +const mockOnSubmit = vi.fn(); +const mockOnErrorDismiss = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("UpdatePasswordForm", () => { + it("renders form with password inputs", () => { + const { getByLabelText, getByText } = render(); + + expect(getByText("Update Your Password")).toBeInTheDocument(); + expect(getByLabelText("New password")).toBeInTheDocument(); + expect(getByLabelText("Confirm password")).toBeInTheDocument(); + expect(getByText("Continue")).toBeInTheDocument(); + }); + + it("allows entering password values", () => { + const { getByLabelText } = render(); + + const newPasswordInput = getByLabelText("New password"); + const confirmPasswordInput = getByLabelText("Confirm password"); + + fireEvent.change(newPasswordInput, { target: { value: "NewPass123!@#" } }); + fireEvent.change(confirmPasswordInput, { + target: { value: "NewPass123!@#" }, + }); + + expect(newPasswordInput).toHaveValue("NewPass123!@#"); + expect(confirmPasswordInput).toHaveValue("NewPass123!@#"); + }); + + it("calls onSubmit with password values when Continue is clicked", () => { + const { getByLabelText, getByText } = render(); + + const newPasswordInput = getByLabelText("New password"); + const confirmPasswordInput = getByLabelText("Confirm password"); + + fireEvent.change(newPasswordInput, { target: { value: "NewPass123" } }); + fireEvent.change(confirmPasswordInput, { target: { value: "NewPass123" } }); + + fireEvent.click(getByText("Continue")); + + expect(mockOnSubmit).toHaveBeenCalledWith("NewPass123", "NewPass123"); + }); + + it("displays error message when provided", () => { + const { getByText } = render(); + + expect(getByText("Passwords do not match")).toBeInTheDocument(); + }); + + it("calls onErrorDismiss when typing in new password field", () => { + const { getByLabelText } = render( + , + ); + + const newPasswordInput = getByLabelText("New password"); + fireEvent.change(newPasswordInput, { target: { value: "test" } }); + + expect(mockOnErrorDismiss).toHaveBeenCalled(); + }); + + it("calls onErrorDismiss when typing in confirm password field", () => { + const { getByLabelText } = render( + , + ); + + const confirmPasswordInput = getByLabelText("Confirm password"); + fireEvent.change(confirmPasswordInput, { target: { value: "test" } }); + + expect(mockOnErrorDismiss).toHaveBeenCalled(); + }); + + it("shows loading state when isSubmitting is true", () => { + const { getByText } = render(); + + expect(getByText("Updating...")).toBeInTheDocument(); + }); + + it("disables button when isSubmitting is true", () => { + const { getByText } = render(); + + const continueButton = getByText("Updating...").closest("button"); + expect(continueButton).toBeDisabled(); + }); + + it("renders password strength meter", () => { + const { getByText } = render(); + + expect(getByText("Password strength")).toBeInTheDocument(); + }); + + it("renders Logo component", () => { + const { container } = render(); + + const logo = container.querySelector("svg"); + expect(logo).toBeTruthy(); + }); + + it("renders Footer component", () => { + const { container } = render(); + + expect(container.querySelector("footer")).toBeTruthy(); + }); + + it("displays error message with correct styling", () => { + const { getByText, getByTestId } = render( + , + ); + + expect(getByText("Invalid password format")).toBeInTheDocument(); + expect(getByTestId("callout")).toBeInTheDocument(); + }); + + it("shows validation error when submitting with empty passwords", () => { + const { getByText } = render(); + + fireEvent.click(getByText("Continue")); + + expect(mockOnSubmit).not.toHaveBeenCalled(); + expect(getByText("Minimum 8 characters required")).toBeInTheDocument(); + }); + + it("updates password strength meter when password changes", () => { + const { getByLabelText } = render(); + + const newPasswordInput = getByLabelText("New password"); + + fireEvent.change(newPasswordInput, { target: { value: "weak" } }); + + fireEvent.change(newPasswordInput, { + target: { value: "StrongP@ssw0rd123!" }, + }); + + expect(newPasswordInput).toHaveValue("StrongP@ssw0rd123!"); + }); + + it("handles long error messages", () => { + const longError = + "Password must be at least 12 characters long and include uppercase letters, lowercase letters, numbers, and special characters. Please try again."; + + const { getByText } = render(); + + expect(getByText(longError)).toBeInTheDocument(); + }); + + it("does not show error message when errorMsg is empty string", () => { + const { queryByTestId } = render(); + + expect(queryByTestId("callout")).toBeFalsy(); + }); + + it("renders descriptive text about temporary password", () => { + const { getByText } = render(); + + expect( + getByText("You logged in with a temporary password. Enter your new password to continue."), + ).toBeInTheDocument(); + }); +}); diff --git a/client/src/protoFleet/features/auth/components/UpdatePasswordForm.tsx b/client/src/protoFleet/features/auth/components/UpdatePasswordForm.tsx new file mode 100644 index 000000000..0ed576587 --- /dev/null +++ b/client/src/protoFleet/features/auth/components/UpdatePasswordForm.tsx @@ -0,0 +1,121 @@ +import { useCallback, useState } from "react"; +import Footer from "@/protoFleet/components/Footer"; +import { Alert, Logo } from "@/shared/assets/icons"; +import Button from "@/shared/components/Button"; +import Callout from "@/shared/components/Callout"; +import Header from "@/shared/components/Header"; +import Input from "@/shared/components/Input"; +import { PasswordStrengthMeter, WeakPasswordWarning } from "@/shared/components/Setup"; +import { isPasswordTooShort, isWeakPassword, passwordErrors } from "@/shared/components/Setup/authentication.constants"; + +interface UpdatePasswordFormProps { + onSubmit: (newPassword: string, confirmPassword: string) => void; + isSubmitting?: boolean; + errorMsg?: string; + onErrorDismiss?: () => void; +} + +export const UpdatePasswordForm = ({ + onSubmit, + isSubmitting = false, + errorMsg = "", + onErrorDismiss, +}: UpdatePasswordFormProps) => { + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [score, setScore] = useState(0); + const [validationError, setValidationError] = useState(""); + const [showWeakPasswordWarning, setShowWeakPasswordWarning] = useState(false); + + const handlePasswordChange = (value: string) => { + setNewPassword(value); + setValidationError(""); + onErrorDismiss?.(); + }; + + const handleConfirmPasswordChange = (value: string) => { + setConfirmPassword(value); + setValidationError(""); + onErrorDismiss?.(); + }; + + const handleSubmit = useCallback( + (forcedWeakPassword: boolean) => { + // Validate password length + if (isPasswordTooShort(newPassword)) { + setValidationError(passwordErrors.tooShort); + return; + } + + // Validate passwords match + if (newPassword !== confirmPassword) { + setValidationError(passwordErrors.mismatch); + return; + } + + // Check for weak password + if (!forcedWeakPassword && isWeakPassword(score)) { + setShowWeakPasswordWarning(true); + return; + } + + setShowWeakPasswordWarning(false); + onSubmit(newPassword, confirmPassword); + }, + [newPassword, confirmPassword, score, onSubmit], + ); + + return ( +
+
+
+
+ +
+
+ + {errorMsg || validationError ? ( + } title={errorMsg || validationError} /> + ) : null} + +
+
+ +
+
+
Password strength
+
+ +
+
+ + +
+ + {showWeakPasswordWarning && !isSubmitting && ( + setShowWeakPasswordWarning(false)} + onContinue={() => handleSubmit(true)} + /> + )} + + +
+
+
+
+
+
+ ); +}; diff --git a/client/src/protoFleet/features/auth/components/UpdatePasswordSuccess.stories.tsx b/client/src/protoFleet/features/auth/components/UpdatePasswordSuccess.stories.tsx new file mode 100644 index 000000000..4274ec1f1 --- /dev/null +++ b/client/src/protoFleet/features/auth/components/UpdatePasswordSuccess.stories.tsx @@ -0,0 +1,35 @@ +import { action } from "storybook/actions"; +import { UpdatePasswordSuccess } from "./UpdatePasswordSuccess"; + +export default { + title: "Proto Fleet/Auth/UpdatePasswordSuccess", + component: UpdatePasswordSuccess, +}; + +// Default story +export const Default = () => { + return ( + { + action("onLogin")(); + }} + /> + ); +}; + +// Interactive demo +export const Interactive = () => { + return ( +
+
+ Click the "Login" button to proceed to the login screen +
+ { + action("onLogin")(); + alert("Redirecting to login..."); + }} + /> +
+ ); +}; diff --git a/client/src/protoFleet/features/auth/components/UpdatePasswordSuccess.test.tsx b/client/src/protoFleet/features/auth/components/UpdatePasswordSuccess.test.tsx new file mode 100644 index 000000000..c68b1392b --- /dev/null +++ b/client/src/protoFleet/features/auth/components/UpdatePasswordSuccess.test.tsx @@ -0,0 +1,85 @@ +import { fireEvent, render } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { UpdatePasswordSuccess } from "./UpdatePasswordSuccess"; + +const mockOnLogin = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("UpdatePasswordSuccess", () => { + it("renders success message", () => { + const { getByText } = render(); + + expect(getByText("Password saved")).toBeInTheDocument(); + expect(getByText("Password updated.")).toBeInTheDocument(); + }); + + it("renders Login button", () => { + const { getByText } = render(); + + expect(getByText("Login")).toBeInTheDocument(); + }); + + it("calls onLogin when Login button is clicked", () => { + const { getByText } = render(); + + fireEvent.click(getByText("Login")); + + expect(mockOnLogin).toHaveBeenCalled(); + }); + + it("renders Logo component", () => { + const { container } = render(); + + const logo = container.querySelector("svg"); + expect(logo).toBeTruthy(); + }); + + it("renders Footer component", () => { + const { container } = render(); + + expect(container.querySelector("footer")).toBeTruthy(); + }); + + it("uses correct heading size", () => { + const { getByText } = render(); + + const heading = getByText("Password saved"); + expect(heading.className).toContain("text-heading-300"); + }); + + it("button has primary variant styling", () => { + const { getByText } = render(); + + const button = getByText("Login"); + expect(button).toBeInTheDocument(); + }); + + it("renders with proper layout structure", () => { + const { container } = render(); + + const mainContainer = container.querySelector(".h-screen"); + expect(mainContainer).toBeTruthy(); + + const contentWrapper = container.querySelector(".max-w-100"); + expect(contentWrapper).toBeTruthy(); + }); + + it("calls onLogin when button is clicked", () => { + const { getByText } = render(); + + const loginButton = getByText("Login"); + + fireEvent.click(loginButton); + expect(mockOnLogin).toHaveBeenCalledTimes(1); + }); + + it("renders description text with correct styling", () => { + const { getByText } = render(); + + const description = getByText("Password updated."); + expect(description.className).toContain("text-300"); + }); +}); diff --git a/client/src/protoFleet/features/auth/components/UpdatePasswordSuccess.tsx b/client/src/protoFleet/features/auth/components/UpdatePasswordSuccess.tsx new file mode 100644 index 000000000..8f7d3d3d2 --- /dev/null +++ b/client/src/protoFleet/features/auth/components/UpdatePasswordSuccess.tsx @@ -0,0 +1,29 @@ +import Footer from "@/protoFleet/components/Footer"; +import { Logo } from "@/shared/assets/icons"; +import Button from "@/shared/components/Button"; +import Header from "@/shared/components/Header"; + +interface UpdatePasswordSuccessProps { + onLogin: () => void; +} + +export const UpdatePasswordSuccess = ({ onLogin }: UpdatePasswordSuccessProps) => { + return ( +
+
+
+
+ +
+
+ +
+
+
+
+
+
+ ); +}; diff --git a/client/src/protoFleet/features/auth/components/index.ts b/client/src/protoFleet/features/auth/components/index.ts new file mode 100644 index 000000000..01d48e330 --- /dev/null +++ b/client/src/protoFleet/features/auth/components/index.ts @@ -0,0 +1,2 @@ +export { UpdatePasswordForm } from "./UpdatePasswordForm"; +export { UpdatePasswordSuccess } from "./UpdatePasswordSuccess"; diff --git a/client/src/protoFleet/features/auth/pages/Auth/Auth.tsx b/client/src/protoFleet/features/auth/pages/Auth/Auth.tsx new file mode 100644 index 000000000..b5032ed6e --- /dev/null +++ b/client/src/protoFleet/features/auth/pages/Auth/Auth.tsx @@ -0,0 +1,32 @@ +import { useCallback } from "react"; +import { useNavigate as useReactNavigate } from "react-router-dom"; +import Footer from "@/protoFleet/components/Footer"; +import LoginForm from "@/protoFleet/features/auth/components/LoginModal/LoginForm"; + +const Auth = () => { + const navigate = useReactNavigate(); + + const handleLoginSuccess = useCallback( + (requiresPasswordChange: boolean) => { + if (requiresPasswordChange) { + navigate("/update-password"); + } else { + navigate("/"); + } + }, + [navigate], + ); + + return ( +
+
+
+ +
+
+
+
+ ); +}; + +export default Auth; diff --git a/client/src/protoFleet/features/auth/pages/Auth/index.ts b/client/src/protoFleet/features/auth/pages/Auth/index.ts new file mode 100644 index 000000000..0e61b03ba --- /dev/null +++ b/client/src/protoFleet/features/auth/pages/Auth/index.ts @@ -0,0 +1,3 @@ +import Auth from "./Auth"; + +export default Auth; diff --git a/client/src/protoFleet/features/auth/pages/UpdatePassword/UpdatePassword.tsx b/client/src/protoFleet/features/auth/pages/UpdatePassword/UpdatePassword.tsx new file mode 100644 index 000000000..bdae8c12c --- /dev/null +++ b/client/src/protoFleet/features/auth/pages/UpdatePassword/UpdatePassword.tsx @@ -0,0 +1,84 @@ +import { useCallback, useEffect, useState } from "react"; +import { useAuth } from "@/protoFleet/api/useAuth"; +import { UpdatePasswordForm, UpdatePasswordSuccess } from "@/protoFleet/features/auth/components"; +import { useSetTemporaryPassword, useTemporaryPassword } from "@/protoFleet/store"; +import { pushToast, STATUSES as TOAST_STATUSES } from "@/shared/features/toaster"; +import { useNavigate } from "@/shared/hooks/useNavigate"; + +const UpdatePassword = () => { + const navigate = useNavigate(); + const { updatePassword } = useAuth(); + const temporaryPassword = useTemporaryPassword(); + const setTemporaryPassword = useSetTemporaryPassword(); + + const [errorMsg, setErrorMsg] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isSuccess, setIsSuccess] = useState(false); + + // Redirect to login if no temporary password is available on mount + useEffect(() => { + if (!temporaryPassword) { + pushToast({ + message: "Session expired. Please log in again.", + status: TOAST_STATUSES.error, + }); + navigate("/"); + } + // Only check on initial mount, not when temporaryPassword changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Clear temporary password on unmount for security + useEffect(() => { + return () => { + setTemporaryPassword(null); + }; + }, [setTemporaryPassword]); + + const handleUpdatePassword = useCallback( + (newPassword: string, _confirmPassword: string) => { + // Form handles validation (password length, match, weak password warning) + setIsSubmitting(true); + setErrorMsg(""); + + updatePassword({ + currentPassword: temporaryPassword!, + newPassword, + onSuccess: () => { + setTemporaryPassword(null); + setIsSuccess(true); + pushToast({ + message: "Password updated", + status: TOAST_STATUSES.success, + }); + }, + onError: (error: string) => { + setErrorMsg(error || "Failed to update password. Please try again."); + }, + onFinally: () => { + setIsSubmitting(false); + }, + }); + }, + [temporaryPassword, updatePassword, setTemporaryPassword], + ); + + const handleLogin = useCallback(() => { + navigate("/"); + }, [navigate]); + + if (isSuccess) { + return ; + } + + return ( + setErrorMsg("")} + /> + ); +}; + +export default UpdatePassword; diff --git a/client/src/protoFleet/features/auth/pages/UpdatePassword/index.ts b/client/src/protoFleet/features/auth/pages/UpdatePassword/index.ts new file mode 100644 index 000000000..f7a6dd53b --- /dev/null +++ b/client/src/protoFleet/features/auth/pages/UpdatePassword/index.ts @@ -0,0 +1 @@ +export { default } from "./UpdatePassword"; diff --git a/client/src/protoFleet/features/dashboard/components/ChartWidget/ChartWidget.stories.tsx b/client/src/protoFleet/features/dashboard/components/ChartWidget/ChartWidget.stories.tsx new file mode 100644 index 000000000..78f50f816 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/ChartWidget/ChartWidget.stories.tsx @@ -0,0 +1,220 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import ChartWidget from "./ChartWidget"; +import LineChart from "@/protoFleet/components/LineChart"; +import { ChartData } from "@/shared/components/LineChart/types"; + +// Generate sample chart data +const generateSampleData = (): ChartData[] => { + const now = Date.now(); + const data: ChartData[] = []; + const baseValue = 450; + + // Generate 24 hours of data points (every hour) + for (let i = 0; i < 24; i++) { + const timestamp = now - (23 - i) * 60 * 60 * 1000; + const variation = Math.sin(i / 3) * 50 + Math.random() * 20; + + data.push({ + datetime: timestamp, + totalHashrate: baseValue + variation, + }); + } + + return data; +}; + +const meta: Meta = { + title: "Proto Fleet/Dashboard/ChartWidget", + component: ChartWidget, + parameters: { + layout: "centered", + docs: { + description: { + component: + "ChartWidget is a container component that displays a title, optional stats, and chart content. It can display a single stat or multiple stats in a grid layout.", + }, + }, + }, + tags: ["autodocs"], + argTypes: { + statsSize: { + control: "select", + options: ["small", "medium", "large"], + description: "Size of the stats display", + }, + statsGrid: { + control: "text", + description: "Tailwind grid class for stats layout", + }, + className: { + control: "text", + description: "Additional CSS classes", + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const SingleStat: Story = { + args: { + stats: { + label: "Current", + value: "230.2", + units: "TH/s", + }, + }, + render: (args) => { + const sampleData = generateSampleData(); + return ( + + + + ); + }, +}; + +export const MultipleStats: Story = { + args: { + stats: [ + { + label: "Hashrate", + value: "230.2", + units: "TH/s", + }, + { + label: "Efficiency", + value: "22.5", + units: "J/TH", + }, + { + label: "Temperature", + value: "65°C", + units: "Average", + }, + ], + statsGrid: "grid-cols-3", + statsGap: "gap-x-8", + }, + render: (args) => { + const sampleData = generateSampleData(); + return ( + + + + ); + }, +}; + +export const NoStats: Story = { + args: {}, + render: (args) => { + const sampleData = generateSampleData(); + return ( + + + + ); + }, +}; + +export const PercentageStats: Story = { + args: { + stats: [ + { + label: "Overall", + value: "85%", + units: "Utilization", + }, + { + label: "Active", + value: "178", + units: "miners", + }, + { + label: "Offline", + value: "22", + units: "miners", + }, + ], + statsGrid: "grid-cols-3", + statsSize: "medium", + }, + render: (args) => { + const sampleData = generateSampleData(); + return ( + + + + ); + }, +}; + +export const SmallStats: Story = { + args: { + stats: [ + { + label: "Min", + value: "220.1", + units: "TH/s", + }, + { + label: "Avg", + value: "230.2", + units: "TH/s", + }, + { + label: "Max", + value: "245.8", + units: "TH/s", + }, + ], + statsGrid: "grid-cols-3", + statsSize: "small", + }, + render: (args) => { + const sampleData = generateSampleData(); + return ( + + + + ); + }, +}; diff --git a/client/src/protoFleet/features/dashboard/components/ChartWidget/ChartWidget.test.tsx b/client/src/protoFleet/features/dashboard/components/ChartWidget/ChartWidget.test.tsx new file mode 100644 index 000000000..5d7bac8ef --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/ChartWidget/ChartWidget.test.tsx @@ -0,0 +1,109 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import ChartWidget from "./ChartWidget"; + +describe("ChartWidget", () => { + it("renders single stat", () => { + render( + +
Chart Content
+
, + ); + + expect(screen.getByText("Hashrate")).toBeInTheDocument(); + expect(screen.getByText("230.2")).toBeInTheDocument(); + expect(screen.getByText("TH/s")).toBeInTheDocument(); + }); + + it("renders multiple stats", () => { + render( + +
Chart Content
+
, + ); + + expect(screen.getByText("Hashrate")).toBeInTheDocument(); + expect(screen.getByText("230.2")).toBeInTheDocument(); + expect(screen.getByText("TH/s")).toBeInTheDocument(); + expect(screen.getByText("Efficiency")).toBeInTheDocument(); + expect(screen.getByText("67.0")).toBeInTheDocument(); + expect(screen.getByText("J/TH")).toBeInTheDocument(); + expect(screen.getByText("Temperature")).toBeInTheDocument(); + expect(screen.getByText("65°")).toBeInTheDocument(); + expect(screen.getByText("Average")).toBeInTheDocument(); + }); + + it("renders without stats", () => { + render( + +
Chart Content
+
, + ); + + expect(screen.getByText("Chart Content")).toBeInTheDocument(); + }); + + it("renders children correctly", () => { + render( + +
Mock Chart
+
, + ); + + expect(screen.getByTestId("chart-content")).toBeInTheDocument(); + expect(screen.getByText("Mock Chart")).toBeInTheDocument(); + }); + + it("applies custom className", () => { + const { container } = render( + +
Chart Content
+
, + ); + + const widget = container.firstChild as HTMLElement; + expect(widget).toHaveClass("custom-class"); + expect(widget).toHaveClass("rounded-xl"); + expect(widget).toHaveClass("bg-surface-base"); + expect(widget).toHaveClass("p-10"); + }); + + it("handles percentage values with special formatting", () => { + render( + +
Chart Content
+
, + ); + + expect(screen.getByText("85%")).toBeInTheDocument(); + expect(screen.getByText("Current")).toBeInTheDocument(); + }); + + it("uses custom stats configuration", () => { + const { container } = render( + +
Chart Content
+
, + ); + + // Check that Stats component is rendered (would have the grid class) + const statsContainer = container.querySelector(".grid-cols-2"); + expect(statsContainer).toBeInTheDocument(); + }); +}); diff --git a/client/src/protoFleet/features/dashboard/components/ChartWidget/ChartWidget.tsx b/client/src/protoFleet/features/dashboard/components/ChartWidget/ChartWidget.tsx new file mode 100644 index 000000000..a4e8577a5 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/ChartWidget/ChartWidget.tsx @@ -0,0 +1,48 @@ +import { ReactNode } from "react"; +import clsx from "clsx"; +import type { StatProps } from "@/shared/components/Stat"; +import Stats from "@/shared/components/Stats"; + +type ChartWidgetStat = Omit; + +type ChartWidgetProps = { + stats?: ChartWidgetStat | ChartWidgetStat[]; + children: ReactNode; + className?: string; + statsGrid?: string; + statsGap?: string; + statsPadding?: string; + statsSize?: StatProps["size"]; +}; + +const ChartWidget = ({ + stats, + children, + className, + statsGrid = "grid-cols-1", + statsGap = "gap-4", + statsPadding = "pb-6", + statsSize = "large", +}: ChartWidgetProps) => { + // Normalize stats to always be an array + const statsArray = stats ? (Array.isArray(stats) ? stats : [stats]) : []; + + return ( +
+
+ {statsArray.length > 0 && ( + + )} +
+
{children}
+
+ ); +}; + +export default ChartWidget; diff --git a/client/src/protoFleet/features/dashboard/components/ChartWidget/index.ts b/client/src/protoFleet/features/dashboard/components/ChartWidget/index.ts new file mode 100644 index 000000000..ee89f922f --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/ChartWidget/index.ts @@ -0,0 +1,3 @@ +import ChartWidget from "./ChartWidget"; + +export default ChartWidget; diff --git a/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/EfficiencyPanel.test.tsx b/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/EfficiencyPanel.test.tsx new file mode 100644 index 000000000..63e4b0f0b --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/EfficiencyPanel.test.tsx @@ -0,0 +1,84 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import { EfficiencyPanel } from "./EfficiencyPanel"; +import { + AggregatedValueSchema, + AggregationType, + MeasurementType, + type Metric, + MetricSchema, +} from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; + +// Helper function to create mock Metric with device count +const createMockMetric = (avgValue: number, deviceCount: number): Metric => { + return create(MetricSchema, { + measurementType: MeasurementType.EFFICIENCY, + openTime: { + seconds: BigInt(Math.floor(Date.now() / 1000)), + nanos: 0, + }, + aggregatedValues: [ + create(AggregatedValueSchema, { + aggregationType: AggregationType.AVERAGE, + value: avgValue, + }), + ], + deviceCount, + }); +}; + +describe("EfficiencyPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("shows subtitle when not all miners are reporting", () => { + const metrics = [createMockMetric(25.5, 3)]; + + render(); + + expect(screen.getByText("3 of 5 miners reporting")).toBeInTheDocument(); + }); + + it("hides subtitle when all miners are reporting", () => { + const metrics = [createMockMetric(25.5, 5)]; + + render(); + + expect(screen.queryByText(/miners reporting/)).not.toBeInTheDocument(); + }); + + it("hides subtitle when device count is null", () => { + // No metrics, so device count will be null + render(); + + expect(screen.queryByText(/miners reporting/)).not.toBeInTheDocument(); + }); + + it("shows subtitle with zero miners reporting", () => { + const metrics = [createMockMetric(0, 0)]; + + render(); + + expect(screen.getByText("0 of 5 miners reporting")).toBeInTheDocument(); + }); + + it("uses max device count across buckets, not the last bucket", () => { + // Arrange — first bucket has 5 devices, second (incomplete) bucket has only 3 + const metrics = [createMockMetric(25.5, 5), createMockMetric(24.0, 3)]; + + // Act + render(); + + // Assert — subtitle should reflect the max (5), not the last bucket (3) + expect(screen.getByText("5 of 7 miners reporting")).toBeInTheDocument(); + }); + + it("renders loading state without subtitle", () => { + // undefined = not loaded yet (loading state) + render(); + + expect(screen.queryByText(/miners reporting/)).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/EfficiencyPanel.tsx b/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/EfficiencyPanel.tsx new file mode 100644 index 000000000..48567b063 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/EfficiencyPanel.tsx @@ -0,0 +1,97 @@ +import { useMemo } from "react"; +import { transformEfficiencyMetricsToChartData } from "./utils"; +import { type Metric } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import LineChart from "@/protoFleet/components/LineChart"; +import ChartWidget from "@/protoFleet/features/dashboard/components/ChartWidget"; +import { padChartDataWithNulls } from "@/protoFleet/features/dashboard/utils/chartDataPadding"; +import { getMinerCountSubtitle } from "@/protoFleet/features/dashboard/utils/minerCountSubtitle"; +import { FleetDuration } from "@/shared/components/DurationSelector"; +import SkeletonBar from "@/shared/components/SkeletonBar"; + +interface EfficiencyPanelProps { + duration: FleetDuration; + /** Efficiency metrics — undefined = not loaded yet, empty array = loaded but no data */ + metrics: Metric[] | undefined; + /** Total miner count for "X of Y miners reporting" subtitle */ + totalMiners: number; +} + +export function EfficiencyPanel({ duration, metrics, totalMiners }: EfficiencyPanelProps) { + // Transform metrics data to chart format (merging already done by store selectors) + const chartData = useMemo(() => { + if (metrics === undefined) return undefined; // Not loaded yet + if (metrics.length === 0) return null; // Loaded but no data + + const transformedData = transformEfficiencyMetricsToChartData(metrics); + + // Pad with null values for the full duration + return padChartDataWithNulls(transformedData, duration); + }, [metrics, duration]); + + // Get the latest efficiency value for the stat display + const currentEfficiency = useMemo(() => { + if (chartData === undefined) return undefined; // Not loaded yet + if (chartData === null || chartData.length === 0) return null; // Loaded but no data + return chartData[chartData.length - 1]?.efficiency ?? null; + }, [chartData]); + + // Use max device count across all buckets — the last bucket may be incomplete + // and fluctuate as new data arrives. + const deviceCount = useMemo(() => { + if (metrics === undefined) return undefined; + if (metrics.length === 0) return null; + return Math.max(...metrics.map((m) => m.deviceCount)); + }, [metrics]); + + // Show loading skeleton while data hasn't loaded yet + if (metrics === undefined) { + const stat = { + label: "Efficiency", + value: undefined, + units: "", + }; + + return ( + + + + ); + } + + // Handle no data case - still show the widget with header but no chart + if (!chartData || chartData.length === 0) { + const stat = { + label: "Efficiency", + value: "No data", + units: "", + }; + + return {null}; + } + + const efficiencyDisplayValue = + currentEfficiency !== null && currentEfficiency !== undefined ? currentEfficiency.toFixed(1) : "N/A"; + + const subtitle = getMinerCountSubtitle(deviceCount ?? null, totalMiners); + const stat = { + label: "Efficiency", + value: efficiencyDisplayValue, + units: "J/TH", + subtitle, + tooltipContent: subtitle ? "Some devices do not make this data available to Proto Fleet." : undefined, + }; + + return ( + + + + ); +} diff --git a/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/index.ts b/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/index.ts new file mode 100644 index 000000000..71f505586 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/index.ts @@ -0,0 +1 @@ +export { EfficiencyPanel } from "./EfficiencyPanel"; diff --git a/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/utils.test.ts b/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/utils.test.ts new file mode 100644 index 000000000..2671f2d8a --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/utils.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { transformEfficiencyMetricsToChartData } from "./utils"; +import { MeasurementType } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { createMockMetric } from "@/protoFleet/features/dashboard/utils/createMockMetric"; + +describe("transformEfficiencyMetricsToChartData", () => { + it("returns empty array for empty metrics", () => { + expect(transformEfficiencyMetricsToChartData([])).toEqual([]); + }); + + it("keeps already-normalized values unchanged", () => { + const metrics = [createMockMetric(MeasurementType.EFFICIENCY, 24.4, 1000)]; + const result = transformEfficiencyMetricsToChartData(metrics); + + expect(result).toEqual([{ datetime: 1000000, efficiency: 24.4 }]); + }); + + it("normalizes large over-converted values back to J/TH", () => { + const metrics = [createMockMetric(MeasurementType.EFFICIENCY, 24.4e12, 1000)]; + const result = transformEfficiencyMetricsToChartData(metrics); + + expect(result).toEqual([{ datetime: 1000000, efficiency: 24.4 }]); + }); +}); diff --git a/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/utils.ts b/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/utils.ts new file mode 100644 index 000000000..45f48b958 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/utils.ts @@ -0,0 +1,28 @@ +import { AggregationType, type Metric } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { normalizeEfficiencyToJTH } from "@/protoFleet/features/dashboard/utils/metricNormalization"; +import type { ChartData } from "@/shared/components/LineChart/types"; + +/** + * Transform efficiency metrics from the API to chart data format + * @param metrics - Array of Metric objects from GetCombinedMetricsResponse + * @returns Array of ChartData objects for LineChart + */ +export function transformEfficiencyMetricsToChartData(metrics: Metric[]): ChartData[] { + if (!metrics || metrics.length === 0) { + return []; + } + + return metrics.map((metric) => { + // Find the AVERAGE aggregation value, default to the first value if not found + const avgValue = + metric.aggregatedValues.find((agg) => agg.aggregationType === AggregationType.AVERAGE)?.value ?? + metric.aggregatedValues[0]?.value ?? + 0; + const normalizedEfficiency = normalizeEfficiencyToJTH(avgValue); + + return { + datetime: Number(metric.openTime?.seconds ?? 0) * 1000, // Convert seconds to milliseconds + efficiency: normalizedEfficiency, + }; + }); +} diff --git a/client/src/protoFleet/features/dashboard/components/FleetHealth/FleetHealth.stories.tsx b/client/src/protoFleet/features/dashboard/components/FleetHealth/FleetHealth.stories.tsx new file mode 100644 index 000000000..323fad969 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/FleetHealth/FleetHealth.stories.tsx @@ -0,0 +1,136 @@ +import { BrowserRouter } from "react-router-dom"; +import type { Meta, StoryObj } from "@storybook/react"; +import FleetHealth from "./FleetHealth"; + +const meta: Meta = { + title: "Proto Fleet/Dashboard/FleetHealth", + component: FleetHealth, + parameters: { + withRouter: false, + layout: "centered", + docs: { + description: { + component: "Displays fleet health statistics with a composition bar visualization", + }, + }, + }, + tags: ["autodocs"], + argTypes: { + fleetSize: { + control: { type: "number", min: 0, max: 1000, step: 1 }, + description: "Total number of miners in the fleet", + }, + healthyMiners: { + control: { type: "number", min: 0, max: 1000, step: 1 }, + description: "Number of healthy/active miners", + }, + needsAttentionMiners: { + control: { type: "number", min: 0, max: 1000, step: 1 }, + description: "Number of miners needing attention (ERROR or AUTHENTICATION_NEEDED)", + }, + offlineMiners: { + control: { type: "number", min: 0, max: 1000, step: 1 }, + description: "Number of offline miners", + }, + sleepingMiners: { + control: { type: "number", min: 0, max: 1000, step: 1 }, + description: "Number of sleeping/inactive miners", + }, + }, + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + fleetSize: 200, + healthyMiners: 178, + needsAttentionMiners: 15, + offlineMiners: 5, + sleepingMiners: 2, + }, +}; + +export const AllHealthy: Story = { + args: { + fleetSize: 100, + healthyMiners: 100, + needsAttentionMiners: 0, + offlineMiners: 0, + sleepingMiners: 0, + }, +}; + +export const MostlyHealthy: Story = { + args: { + fleetSize: 100, + healthyMiners: 85, + needsAttentionMiners: 5, + offlineMiners: 5, + sleepingMiners: 5, + }, +}; + +export const Warning: Story = { + args: { + fleetSize: 100, + healthyMiners: 70, + needsAttentionMiners: 15, + offlineMiners: 10, + sleepingMiners: 5, + }, +}; + +export const Critical: Story = { + args: { + fleetSize: 100, + healthyMiners: 30, + needsAttentionMiners: 40, + offlineMiners: 20, + sleepingMiners: 10, + }, +}; + +export const SmallFleet: Story = { + args: { + fleetSize: 10, + healthyMiners: 7, + needsAttentionMiners: 1, + offlineMiners: 1, + sleepingMiners: 1, + }, +}; + +export const LargeFleet: Story = { + args: { + fleetSize: 1000, + healthyMiners: 850, + needsAttentionMiners: 80, + offlineMiners: 50, + sleepingMiners: 20, + }, +}; + +export const Loading: Story = { + args: { + // All props undefined to show loading state + }, +}; + +export const PartialLoading: Story = { + args: { + fleetSize: 100, + healthyMiners: 70, + // needsAttentionMiners, offlineMiners, and sleepingMiners undefined + }, +}; diff --git a/client/src/protoFleet/features/dashboard/components/FleetHealth/FleetHealth.test.tsx b/client/src/protoFleet/features/dashboard/components/FleetHealth/FleetHealth.test.tsx new file mode 100644 index 000000000..03af6d604 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/FleetHealth/FleetHealth.test.tsx @@ -0,0 +1,275 @@ +import React from "react"; +import { BrowserRouter } from "react-router-dom"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import FleetHealth from "./FleetHealth"; + +describe("FleetHealth", () => { + const renderWithRouter = (component: React.ReactElement) => { + return render({component}); + }; + + it("renders correct stats when all miners are healthy", () => { + renderWithRouter( + , + ); + + // Check title label + expect(screen.getByText("Your fleet")).toBeInTheDocument(); + + // Check percentages + expect(screen.getByText("100%")).toBeInTheDocument(); // Healthy + + // Check counts - "100 miners" appears twice (header and healthy column) + const healthyCount = screen.getAllByText("100 miners"); + expect(healthyCount).toHaveLength(2); // One in header, one in healthy column + + const zeroMiners = screen.getAllByText("0 miners"); + expect(zeroMiners).toHaveLength(3); // Needs Attention, Offline, and Sleeping columns + + // Check that legend is present + const healthyTexts = screen.getAllByText("Healthy"); + expect(healthyTexts.length).toBeGreaterThan(0); + const needsAttentionTexts = screen.getAllByText("Needs Attention"); + expect(needsAttentionTexts.length).toBeGreaterThan(0); + const offlineTexts = screen.getAllByText("Offline"); + expect(offlineTexts.length).toBeGreaterThan(0); + const sleepingTexts = screen.getAllByText("Sleeping"); + expect(sleepingTexts.length).toBeGreaterThan(0); + + // Check CompositionBar is rendered + const progressBars = screen.getAllByRole("progressbar"); + expect(progressBars.length).toBeGreaterThan(0); + }); + + it("renders correct stats with mixed fleet health", () => { + renderWithRouter( + , + ); + + // Check miner count + expect(screen.getByText("200 miners")).toBeInTheDocument(); + + // Check percentages (85% + 9% + 4% + 2% = 100%) + expect(screen.getByText("85%")).toBeInTheDocument(); // Healthy: 170/200 = 85% + expect(screen.getByText("9%")).toBeInTheDocument(); // Needs Attention: 18/200 = 9% + expect(screen.getByText("4%")).toBeInTheDocument(); // Offline: 8/200 = 4% + expect(screen.getByText("2%")).toBeInTheDocument(); // Sleeping: 4/200 = 2% + + // Check miner counts + expect(screen.getByText("170 miners")).toBeInTheDocument(); + expect(screen.getByText("18 miners")).toBeInTheDocument(); + expect(screen.getByText("8 miners")).toBeInTheDocument(); + expect(screen.getByText("4 miners")).toBeInTheDocument(); + }); + + it("renders stats for fleet with moderate health distribution", () => { + renderWithRouter( + , + ); + + // Check title label + expect(screen.getByText("Your fleet")).toBeInTheDocument(); + + // Check percentages (all unique values) + expect(screen.getByText("60%")).toBeInTheDocument(); // Healthy: 60/100 = 60% + expect(screen.getByText("20%")).toBeInTheDocument(); // Needs Attention: 20/100 = 20% + expect(screen.getByText("12%")).toBeInTheDocument(); // Offline: 12/100 = 12% + expect(screen.getByText("8%")).toBeInTheDocument(); // Sleeping: 8/100 = 8% + }); + + it("renders stats for fleet with critical health distribution", () => { + renderWithRouter( + , + ); + + // Check title label + expect(screen.getByText("Your fleet")).toBeInTheDocument(); + + // Check percentages (all unique values) + expect(screen.getByText("15%")).toBeInTheDocument(); // Healthy: 15/100 = 15% + expect(screen.getByText("50%")).toBeInTheDocument(); // Needs Attention: 50/100 = 50% + expect(screen.getByText("25%")).toBeInTheDocument(); // Offline: 25/100 = 25% + expect(screen.getByText("10%")).toBeInTheDocument(); // Sleeping: 10/100 = 10% + }); + + it("handles division by zero when fleet size is 0", () => { + renderWithRouter( + , + ); + + // Should render without errors + expect(screen.getByText("Your fleet")).toBeInTheDocument(); + + // All percentages should be 0% + const zeroPercents = screen.getAllByText("0%"); + expect(zeroPercents).toHaveLength(4); // Healthy, Needs Attention, Offline, Sleeping + + // All miner counts should be 0 miners (4 in columns, 1 in header = 5 total) + const zeroMinerCounts = screen.getAllByText("0 miners"); + expect(zeroMinerCounts).toHaveLength(5); + }); + + it("renders loading state when miner counts are undefined", () => { + renderWithRouter(); + + // Should render skeleton bars instead of values + expect(screen.getByText("Your fleet")).toBeInTheDocument(); + + // Check that all stat labels are present but with skeleton bars + const healthyTexts = screen.getAllByText("Healthy"); + expect(healthyTexts.length).toBeGreaterThan(0); + const needsAttentionTexts = screen.getAllByText("Needs Attention"); + expect(needsAttentionTexts.length).toBeGreaterThan(0); + const offlineTexts = screen.getAllByText("Offline"); + expect(offlineTexts.length).toBeGreaterThan(0); + const sleepingTexts = screen.getAllByText("Sleeping"); + expect(sleepingTexts.length).toBeGreaterThan(0); + + // Skeleton bars should be present: + // - 5 for stat values (Your fleet + 4 health categories) + // - 5 for composition bar area (1 bar + 4 legend items) + const skeletonBars = screen.getAllByTestId("skeleton-bar"); + expect(skeletonBars.length).toBe(10); + }); + + it("renders full loading state when some props are undefined", () => { + renderWithRouter( + , + ); + + // Check title label is present + expect(screen.getByText("Your fleet")).toBeInTheDocument(); + + // When ANY prop is undefined, show full loading skeleton + // This provides consistent UX rather than showing partial/incomplete data + const skeletonBars = screen.getAllByTestId("skeleton-bar"); + expect(skeletonBars.length).toBe(10); // Full loading state (5 stats + 5 composition bar area) + + // Defined values should NOT be shown (we're in loading state) + expect(screen.queryByText("70%")).not.toBeInTheDocument(); + expect(screen.queryByText("70 miners")).not.toBeInTheDocument(); + }); + + it("renders legend with correct color indicators", () => { + const { container } = renderWithRouter( + , + ); + + // Check legend items + const healthyTexts = screen.getAllByText("Healthy"); + expect(healthyTexts.length).toBeGreaterThan(0); + const needsAttentionTexts = screen.getAllByText("Needs Attention"); + expect(needsAttentionTexts.length).toBeGreaterThan(0); + const offlineTexts = screen.getAllByText("Offline"); + expect(offlineTexts.length).toBeGreaterThan(0); + const sleepingTexts = screen.getAllByText("Sleeping"); + expect(sleepingTexts.length).toBeGreaterThan(0); + + // Check that the triangle SVG exists for needs attention + const svgTriangle = container.querySelector("svg"); + expect(svgTriangle).toBeInTheDocument(); + + // Check color indicators + const greenIndicators = container.querySelectorAll(".bg-core-primary-fill"); + const redIndicators = container.querySelectorAll(".fill-intent-critical-fill, .text-intent-critical-fill"); + const accentIndicators = container.querySelectorAll(".bg-core-accent-fill"); + const primaryIndicators = container.querySelectorAll(".bg-core-primary-20"); + + expect(greenIndicators.length).toBeGreaterThan(0); // Healthy + expect(redIndicators.length).toBeGreaterThan(0); // Needs Attention + expect(accentIndicators.length).toBeGreaterThan(0); // Offline + expect(primaryIndicators.length).toBeGreaterThan(0); // Sleeping + }); + + it("handles pluralization correctly for singular miner", () => { + renderWithRouter( + , + ); + + // Check singular form - should appear in title and Needs Attention stat + const oneMinerText = screen.getAllByText(/1 miner/); + expect(oneMinerText.length).toBe(2); // Once in title, once in Needs Attention stat + }); + + it("handles pluralization correctly for multiple miners", () => { + renderWithRouter( + , + ); + + // Check plural form + expect(screen.getByText("50 miners")).toBeInTheDocument(); + expect(screen.getByText("30 miners")).toBeInTheDocument(); + expect(screen.getByText("10 miners")).toBeInTheDocument(); + expect(screen.getByText("7 miners")).toBeInTheDocument(); + expect(screen.getByText("3 miners")).toBeInTheDocument(); + }); + + it("renders mdash for all stats when counts are null (loaded but no data)", () => { + renderWithRouter( + , + ); + + // Should show mdash (\u2014) for each stat, not skeleton bars + const mdashes = screen.getAllByText("\u2014"); + expect(mdashes).toHaveLength(5); // title + 4 categories + + // No skeleton bars should be present + expect(screen.queryByTestId("skeleton-bar")).not.toBeInTheDocument(); + + // No composition bar or legend should be shown + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + }); + + it("renders mdash state when some counts are null", () => { + renderWithRouter( + , + ); + + // Should show mdash state, not skeleton or data + const mdashes = screen.getAllByText("\u2014"); + expect(mdashes.length).toBeGreaterThan(0); + expect(screen.queryByTestId("skeleton-bar")).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/protoFleet/features/dashboard/components/FleetHealth/FleetHealth.tsx b/client/src/protoFleet/features/dashboard/components/FleetHealth/FleetHealth.tsx new file mode 100644 index 000000000..dd49d4e9a --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/FleetHealth/FleetHealth.tsx @@ -0,0 +1,290 @@ +import { useMemo } from "react"; +import { Link } from "react-router-dom"; +import { create } from "@bufbuild/protobuf"; +import ChartWidget from "../ChartWidget/ChartWidget"; +import { MinerListFilterSchema } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { DeviceStatus } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { encodeFilterToURL } from "@/protoFleet/features/fleetManagement/utils/filterUrlParams"; +import { Triangle } from "@/shared/assets/icons"; +import CompositionBar, { type Segment } from "@/shared/components/CompositionBar"; +import SkeletonBar from "@/shared/components/SkeletonBar"; + +const FleetHealthSkeleton = ({ title = "Your fleet" }: { title?: string }) => ( + +
+
+ +
+
+ + + + +
+
+
+); + +/** undefined = still loading (skeleton), null = loaded but no data (show mdash), number = show value */ +type MinerCount = number | null | undefined; + +interface FleetHealthProps { + fleetSize?: MinerCount; + healthyMiners?: MinerCount; + needsAttentionMiners?: MinerCount; + offlineMiners?: MinerCount; + sleepingMiners?: MinerCount; + /** Override the default "Your fleet" title (e.g., group name) */ + title?: string; + /** Extra URL search params to append to miner list links (e.g., "group=123") */ + extraFilterParams?: string; + /** Link URL for the total miners count (e.g., "/miners?group=123") */ + totalMinersLink?: string; +} + +const FleetHealth = ({ + fleetSize, + healthyMiners, + needsAttentionMiners, + offlineMiners, + sleepingMiners, + title = "Your fleet", + extraFilterParams, + totalMinersLink, +}: FleetHealthProps) => { + // undefined = still loading (show skeleton), null = loaded but no data (show mdash) + const isLoading = + fleetSize === undefined || + healthyMiners === undefined || + needsAttentionMiners === undefined || + offlineMiners === undefined || + sleepingMiners === undefined; + + // When any count is null, we've finished loading but have no data (e.g. API error) + const hasNoData = + fleetSize === null || + healthyMiners === null || + needsAttentionMiners === null || + offlineMiners === null || + sleepingMiners === null; + + // Create enhanced segments with filter URLs + // Note: useMemo must be called unconditionally (Rules of Hooks) + const segmentsWithFilters = useMemo(() => { + // Return empty array during loading or no-data states to satisfy hook requirements + if (isLoading || hasNoData) return []; + + const totalMiners = fleetSize || 1; // prevent division by zero + + // Define segments with their filter configurations + const segmentConfigs = [ + { + name: "Healthy", + status: "OK" as Segment["status"], + count: healthyMiners, + filter: create(MinerListFilterSchema, { + deviceStatus: [DeviceStatus.ONLINE], + }), + clickable: false, // Healthy is not clickable + }, + { + name: "Needs Attention", + status: "CRITICAL" as Segment["status"], + count: needsAttentionMiners, + filter: create(MinerListFilterSchema, { + deviceStatus: [ + DeviceStatus.ERROR, + DeviceStatus.NEEDS_MINING_POOL, + DeviceStatus.UPDATING, + DeviceStatus.REBOOT_REQUIRED, + ], + }), + clickable: true, + }, + { + name: "Offline", + status: "NA" as Segment["status"], + count: offlineMiners, + filter: create(MinerListFilterSchema, { + deviceStatus: [DeviceStatus.OFFLINE], + }), + clickable: true, + }, + { + name: "Sleeping", + status: "WARNING" as Segment["status"], + count: sleepingMiners, + filter: create(MinerListFilterSchema, { + deviceStatus: [DeviceStatus.INACTIVE], + }), + clickable: true, + }, + ]; + + // Add filter URL and percentage to each segment + return segmentConfigs.map((segment) => { + const params = encodeFilterToURL(segment.filter); + if (extraFilterParams) { + new URLSearchParams(extraFilterParams).forEach((value, key) => params.set(key, value)); + } + return { + ...segment, + filterUrl: `/miners?${params.toString()}`, + percentage: segment.count !== undefined ? Math.round((segment.count / totalMiners) * 100) : undefined, + }; + }); + }, [ + fleetSize, + healthyMiners, + needsAttentionMiners, + offlineMiners, + sleepingMiners, + isLoading, + hasNoData, + extraFilterParams, + ]); + + // Extract basic segments for CompositionBar (without extra props) + const segments = useMemo( + () => + segmentsWithFilters.map(({ name, status, count }) => ({ + name, + status, + count, + })), + [segmentsWithFilters], + ); + + // Derive stats from segments + const stats = useMemo( + () => + segmentsWithFilters.map((segment) => { + // Pluralization helper + const minerText = segment.count === 1 ? "miner" : "miners"; + + // Determine if this segment should have a link + const shouldHaveLink = segment.clickable && (segment.count ?? 0) > 0; + + return { + label: segment.name, + value: segment.percentage !== undefined ? `${segment.percentage}%` : undefined, + text: + segment.count !== undefined ? ( + shouldHaveLink ? ( + + {segment.count} {minerText} + + ) : ( + <> + {segment.count} {minerText} + + ) + ) : undefined, + }; + }), + [segmentsWithFilters], + ); + + // Create the title stat for ChartWidget title area + const titleStat = useMemo( + () => ({ + label: title, + value: + fleetSize !== undefined + ? totalMinersLink + ? `${fleetSize}\u200B` + : `${fleetSize} ${fleetSize === 1 ? "miner" : "miners"}` + : undefined, + text: + totalMinersLink && fleetSize !== undefined ? ( + + View all + + ) : undefined, + }), + [fleetSize, title, totalMinersLink], + ); + + if (isLoading) { + return ; + } + + if (hasNoData) { + return ( + + {null} + + ); + } + + return ( + +
+ {/* Composition Bar */} +
+ +
+ + {/* Legend */} +
+
+ + Healthy +
+
+ + Needs Attention +
+
+ + Offline +
+
+ + Sleeping +
+
+
+
+ ); +}; + +export default FleetHealth; diff --git a/client/src/protoFleet/features/dashboard/components/FleetHealth/index.ts b/client/src/protoFleet/features/dashboard/components/FleetHealth/index.ts new file mode 100644 index 000000000..387200f26 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/FleetHealth/index.ts @@ -0,0 +1,3 @@ +import FleetHealth from "./FleetHealth"; + +export default FleetHealth; diff --git a/client/src/protoFleet/features/dashboard/components/HashratePanel/HashratePanel.tsx b/client/src/protoFleet/features/dashboard/components/HashratePanel/HashratePanel.tsx new file mode 100644 index 000000000..bc6df0ccb --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/HashratePanel/HashratePanel.tsx @@ -0,0 +1,88 @@ +import { useMemo } from "react"; +import { transformHashrateMetricsWithUnits } from "./utils"; +import { type Metric } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import LineChart from "@/protoFleet/components/LineChart"; +import ChartWidget from "@/protoFleet/features/dashboard/components/ChartWidget"; +import { padChartDataWithNulls } from "@/protoFleet/features/dashboard/utils/chartDataPadding"; +import { FleetDuration } from "@/shared/components/DurationSelector"; +import SkeletonBar from "@/shared/components/SkeletonBar"; + +interface HashratePanelProps { + duration: FleetDuration; + /** Hashrate metrics — undefined = not loaded yet, empty array = loaded but no data */ + metrics: Metric[] | undefined; +} + +export function HashratePanel({ duration, metrics }: HashratePanelProps) { + // Transform metrics data to chart format with consistent unit scaling + // Both chart data and unit are derived together to ensure consistency + const { chartData, hashrateUnits } = useMemo(() => { + if (metrics === undefined) return { chartData: undefined, hashrateUnits: "" }; // Not loaded yet + if (metrics.length === 0) return { chartData: null, hashrateUnits: "" }; // Loaded but no data + + const { chartData: transformedData, unit } = transformHashrateMetricsWithUnits(metrics); + + // Pad with null values for the full duration + return { + chartData: padChartDataWithNulls(transformedData, duration), + hashrateUnits: unit, + }; + }, [metrics, duration]); + + // Get the latest hashrate value from already-transformed chart data + const currentHashrate = useMemo(() => { + if (chartData === undefined) return undefined; // Not loaded yet + if (chartData === null || chartData.length === 0) return null; // Loaded but no data + return chartData[chartData.length - 1]?.hashrate ?? null; + }, [chartData]); + + // Show loading skeleton while data hasn't loaded yet + if (metrics === undefined) { + const stat = { + label: "Hashrate", + value: undefined, + units: "", + }; + + return ( + + + + ); + } + + // Handle no data case - still show the widget with header but no chart + if (!chartData || chartData.length === 0) { + const stat = { + label: "Hashrate", + value: "No data", + units: "", + }; + + return {null}; + } + + // Format the current hashrate for display + const hashrateDisplayValue = + currentHashrate !== null && currentHashrate !== undefined ? currentHashrate.toFixed(1) : "N/A"; + + const stat = { + label: "Hashrate", + value: hashrateDisplayValue, + units: hashrateUnits, + }; + + return ( + + + + ); +} diff --git a/client/src/protoFleet/features/dashboard/components/HashratePanel/index.ts b/client/src/protoFleet/features/dashboard/components/HashratePanel/index.ts new file mode 100644 index 000000000..8e97416c3 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/HashratePanel/index.ts @@ -0,0 +1 @@ +export { HashratePanel } from "./HashratePanel"; diff --git a/client/src/protoFleet/features/dashboard/components/HashratePanel/utils.test.ts b/client/src/protoFleet/features/dashboard/components/HashratePanel/utils.test.ts new file mode 100644 index 000000000..e5547fca2 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/HashratePanel/utils.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { transformHashrateMetricsToChartData, transformHashrateMetricsWithUnits } from "./utils"; +import { MeasurementType } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { createMockMetric } from "@/protoFleet/features/dashboard/utils/createMockMetric"; + +describe("transformHashrateMetricsToChartData", () => { + it("should return empty array for empty metrics", () => { + expect(transformHashrateMetricsToChartData([])).toEqual([]); + }); + + it("should transform metrics to chart data format", () => { + const metrics = [createMockMetric(MeasurementType.HASHRATE, 500, 1000)]; + const result = transformHashrateMetricsToChartData(metrics); + expect(result).toEqual([{ datetime: 1000000, hashrate: 500 }]); + }); + + it("should normalize raw H/s values into TH/S", () => { + const metrics = [createMockMetric(MeasurementType.HASHRATE, 500e12, 1000)]; + const result = transformHashrateMetricsToChartData(metrics); + expect(result).toEqual([{ datetime: 1000000, hashrate: 500 }]); + }); +}); + +describe("transformHashrateMetricsWithUnits", () => { + it("should return TH/S for empty metrics", () => { + const result = transformHashrateMetricsWithUnits([]); + expect(result).toEqual({ chartData: [], unit: "TH/S" }); + }); + + it("should use TH/S when max value is at threshold (1000)", () => { + const metrics = [createMockMetric(MeasurementType.HASHRATE, 1000, 1000)]; + const result = transformHashrateMetricsWithUnits(metrics); + expect(result.unit).toBe("TH/S"); + expect(result.chartData[0].hashrate).toBe(1000); + }); + + it("should convert to PH/S when max value exceeds threshold (1001)", () => { + const metrics = [createMockMetric(MeasurementType.HASHRATE, 1001, 1000)]; + const result = transformHashrateMetricsWithUnits(metrics); + expect(result.unit).toBe("PH/S"); + expect(result.chartData[0].hashrate).toBe(1.001); + }); + + it("should convert all values when any value exceeds threshold", () => { + const metrics = [ + createMockMetric(MeasurementType.HASHRATE, 500, 1000), + createMockMetric(MeasurementType.HASHRATE, 2000, 2000), + ]; + const result = transformHashrateMetricsWithUnits(metrics); + expect(result.unit).toBe("PH/S"); + expect(result.chartData[0].hashrate).toBe(0.5); + expect(result.chartData[1].hashrate).toBe(2); + }); +}); diff --git a/client/src/protoFleet/features/dashboard/components/HashratePanel/utils.ts b/client/src/protoFleet/features/dashboard/components/HashratePanel/utils.ts new file mode 100644 index 000000000..8c3463865 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/HashratePanel/utils.ts @@ -0,0 +1,65 @@ +import { AggregationType, type Metric } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { normalizeHashrateToTHs } from "@/protoFleet/features/dashboard/utils/metricNormalization"; +import type { ChartData } from "@/shared/components/LineChart/types"; +import { TH_TO_PH_DIVISOR, TH_TO_PH_THRESHOLD } from "@/shared/utils/utility"; + +/** + * Transform hashrate metrics from the API to chart data format + * @param metrics - Array of Metric objects from GetCombinedMetricsResponse + * @returns Array of ChartData objects for LineChart + */ +export function transformHashrateMetricsToChartData(metrics: Metric[]): ChartData[] { + if (!metrics || metrics.length === 0) { + return []; + } + + return metrics.map((metric) => { + // Find the AVERAGE aggregation value, default to the first value if not found + const avgValue = + metric.aggregatedValues.find((agg) => agg.aggregationType === AggregationType.AVERAGE)?.value ?? + metric.aggregatedValues[0]?.value ?? + 0; + const normalizedHashrate = normalizeHashrateToTHs(avgValue, metric.deviceCount); + + return { + datetime: Number(metric.openTime?.seconds ?? 0) * 1000, // Convert seconds to milliseconds + hashrate: normalizedHashrate, + }; + }); +} + +/** + * Transform hashrate metrics to chart data with appropriate unit scaling. + * Automatically converts TH/S to PH/S when values exceed 1000 TH/S. + * @param metrics - Array of Metric objects from GetCombinedMetricsResponse + * @returns Object containing scaled chart data and the appropriate unit string + */ +export function transformHashrateMetricsWithUnits(metrics: Metric[]): { + chartData: ChartData[]; + unit: string; +} { + const rawData = transformHashrateMetricsToChartData(metrics); + + if (rawData.length === 0) { + return { chartData: [], unit: "TH/S" }; + } + + // Find max value to determine if we should use PH/S + const maxValue = Math.max(...rawData.map((d) => d.hashrate ?? 0)); + + if (maxValue > TH_TO_PH_THRESHOLD) { + // Convert all values to PH/S + return { + chartData: rawData.map((d) => ({ + ...d, + hashrate: d.hashrate !== null ? d.hashrate / TH_TO_PH_DIVISOR : null, + })), + unit: "PH/S", + }; + } + + return { + chartData: rawData, + unit: "TH/S", + }; +} diff --git a/client/src/protoFleet/features/dashboard/components/PowerPanel/PowerPanel.test.tsx b/client/src/protoFleet/features/dashboard/components/PowerPanel/PowerPanel.test.tsx new file mode 100644 index 000000000..4c6e0c252 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/PowerPanel/PowerPanel.test.tsx @@ -0,0 +1,84 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import { PowerPanel } from "./PowerPanel"; +import { + AggregatedValueSchema, + AggregationType, + MeasurementType, + type Metric, + MetricSchema, +} from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; + +// Helper function to create mock Metric with device count +const createMockMetric = (avgValue: number, deviceCount: number): Metric => { + return create(MetricSchema, { + measurementType: MeasurementType.POWER, + openTime: { + seconds: BigInt(Math.floor(Date.now() / 1000)), + nanos: 0, + }, + aggregatedValues: [ + create(AggregatedValueSchema, { + aggregationType: AggregationType.AVERAGE, + value: avgValue, + }), + ], + deviceCount, + }); +}; + +describe("PowerPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("shows subtitle when not all miners are reporting", () => { + const metrics = [createMockMetric(1500, 3)]; + + render(); + + expect(screen.getByText("3 of 5 miners reporting")).toBeInTheDocument(); + }); + + it("hides subtitle when all miners are reporting", () => { + const metrics = [createMockMetric(1500, 5)]; + + render(); + + expect(screen.queryByText(/miners reporting/)).not.toBeInTheDocument(); + }); + + it("hides subtitle when device count is null", () => { + // No metrics, so device count will be null + render(); + + expect(screen.queryByText(/miners reporting/)).not.toBeInTheDocument(); + }); + + it("shows subtitle with zero miners reporting", () => { + const metrics = [createMockMetric(0, 0)]; + + render(); + + expect(screen.getByText("0 of 5 miners reporting")).toBeInTheDocument(); + }); + + it("uses max device count across buckets, not the last bucket", () => { + // Arrange — first bucket has 5 devices, second (incomplete) bucket has only 3 + const metrics = [createMockMetric(1500, 5), createMockMetric(1400, 3)]; + + // Act + render(); + + // Assert — subtitle should reflect the max (5), not the last bucket (3) + expect(screen.getByText("5 of 7 miners reporting")).toBeInTheDocument(); + }); + + it("renders loading state without subtitle", () => { + // undefined = not loaded yet (loading state) + render(); + + expect(screen.queryByText(/miners reporting/)).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/protoFleet/features/dashboard/components/PowerPanel/PowerPanel.tsx b/client/src/protoFleet/features/dashboard/components/PowerPanel/PowerPanel.tsx new file mode 100644 index 000000000..cc2abbff3 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/PowerPanel/PowerPanel.tsx @@ -0,0 +1,96 @@ +import { useMemo } from "react"; +import { transformPowerMetricsToChartData } from "./utils"; +import { type Metric } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import LineChart from "@/protoFleet/components/LineChart"; +import ChartWidget from "@/protoFleet/features/dashboard/components/ChartWidget"; +import { padChartDataWithNulls } from "@/protoFleet/features/dashboard/utils/chartDataPadding"; +import { getMinerCountSubtitle } from "@/protoFleet/features/dashboard/utils/minerCountSubtitle"; +import { FleetDuration } from "@/shared/components/DurationSelector"; +import SkeletonBar from "@/shared/components/SkeletonBar"; + +interface PowerPanelProps { + duration: FleetDuration; + /** Power metrics — undefined = not loaded yet, empty array = loaded but no data */ + metrics: Metric[] | undefined; + /** Total miner count for "X of Y miners reporting" subtitle */ + totalMiners: number; +} + +export function PowerPanel({ duration, metrics, totalMiners }: PowerPanelProps) { + // Transform metrics data to chart format (merging already done by store selectors) + const chartData = useMemo(() => { + if (metrics === undefined) return undefined; // Not loaded yet + if (metrics.length === 0) return null; // Loaded but no data + + const transformedData = transformPowerMetricsToChartData(metrics); + + // Pad with null values for the full duration + return padChartDataWithNulls(transformedData, duration); + }, [metrics, duration]); + + // Get the latest power value for the stat display + const currentPower = useMemo(() => { + if (chartData === undefined) return undefined; // Not loaded yet + if (chartData === null || chartData.length === 0) return null; // Loaded but no data + return chartData[chartData.length - 1]?.power ?? null; + }, [chartData]); + + // Use max device count across all buckets — the last bucket may be incomplete + // and fluctuate as new data arrives. + const deviceCount = useMemo(() => { + if (metrics === undefined) return undefined; + if (metrics.length === 0) return null; + return Math.max(...metrics.map((m) => m.deviceCount)); + }, [metrics]); + + // Show loading skeleton while data hasn't loaded yet + if (metrics === undefined) { + const stat = { + label: "Power", + value: undefined, + units: "", + }; + + return ( + + + + ); + } + + // Handle no data case - still show the widget with header but no chart + if (!chartData || chartData.length === 0) { + const stat = { + label: "Power", + value: "No data", + units: "", + }; + + return {null}; + } + + const powerDisplayValue = currentPower !== null && currentPower !== undefined ? currentPower.toFixed(1) : "N/A"; + + const subtitle = getMinerCountSubtitle(deviceCount ?? null, totalMiners); + const stat = { + label: "Power", + value: powerDisplayValue, + units: "kW", + subtitle, + tooltipContent: subtitle ? "Some devices do not make this data available to Proto Fleet." : undefined, + }; + + return ( + + + + ); +} diff --git a/client/src/protoFleet/features/dashboard/components/PowerPanel/index.ts b/client/src/protoFleet/features/dashboard/components/PowerPanel/index.ts new file mode 100644 index 000000000..4e707dc31 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/PowerPanel/index.ts @@ -0,0 +1 @@ +export { PowerPanel } from "./PowerPanel"; diff --git a/client/src/protoFleet/features/dashboard/components/PowerPanel/utils.test.ts b/client/src/protoFleet/features/dashboard/components/PowerPanel/utils.test.ts new file mode 100644 index 000000000..1abb0721b --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/PowerPanel/utils.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { transformPowerMetricsToChartData } from "./utils"; +import { MeasurementType } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { createMockMetric } from "@/protoFleet/features/dashboard/utils/createMockMetric"; + +describe("transformPowerMetricsToChartData", () => { + it("returns empty array for empty metrics", () => { + expect(transformPowerMetricsToChartData([])).toEqual([]); + }); + + it("keeps already-normalized values unchanged", () => { + const metrics = [createMockMetric(MeasurementType.POWER, 3.2, 1000)]; + const result = transformPowerMetricsToChartData(metrics); + + expect(result).toEqual([{ datetime: 1000000, power: 3.2 }]); + }); + + it("normalizes raw watt values to kW", () => { + const metrics = [createMockMetric(MeasurementType.POWER, 3200, 1000)]; + const result = transformPowerMetricsToChartData(metrics); + + expect(result).toEqual([{ datetime: 1000000, power: 3.2 }]); + }); +}); diff --git a/client/src/protoFleet/features/dashboard/components/PowerPanel/utils.ts b/client/src/protoFleet/features/dashboard/components/PowerPanel/utils.ts new file mode 100644 index 000000000..de3032281 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/PowerPanel/utils.ts @@ -0,0 +1,28 @@ +import { AggregationType, type Metric } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { normalizePowerToKW } from "@/protoFleet/features/dashboard/utils/metricNormalization"; +import type { ChartData } from "@/shared/components/LineChart/types"; + +/** + * Transform power metrics from the API to chart data format + * @param metrics - Array of Metric objects from GetCombinedMetricsResponse + * @returns Array of ChartData objects for LineChart + */ +export function transformPowerMetricsToChartData(metrics: Metric[]): ChartData[] { + if (!metrics || metrics.length === 0) { + return []; + } + + return metrics.map((metric) => { + // Find the AVERAGE aggregation value, default to the first value if not found + const avgValue = + metric.aggregatedValues.find((agg) => agg.aggregationType === AggregationType.AVERAGE)?.value ?? + metric.aggregatedValues[0]?.value ?? + 0; + const normalizedPower = normalizePowerToKW(avgValue, metric.deviceCount); + + return { + datetime: Number(metric.openTime?.seconds ?? 0) * 1000, // Convert seconds to milliseconds + power: normalizedPower, + }; + }); +} diff --git a/client/src/protoFleet/features/dashboard/components/SectionHeading/SectionHeading.stories.tsx b/client/src/protoFleet/features/dashboard/components/SectionHeading/SectionHeading.stories.tsx new file mode 100644 index 000000000..071ed3843 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/SectionHeading/SectionHeading.stories.tsx @@ -0,0 +1,46 @@ +import SectionHeadingComponent from "."; +import Button, { sizes, variants } from "@/shared/components/Button"; +import DurationSelector from "@/shared/components/DurationSelector"; + +interface SectionHeadingArgs { + heading: string; + controlType: "none" | "durationSelector" | "button"; +} + +export const SectionHeading = ({ heading, controlType }: SectionHeadingArgs) => { + const renderControls = () => { + switch (controlType) { + case "durationSelector": + return ; + case "button": + return + + , + ); + + expect(screen.getByText("Performance")).toBeInTheDocument(); + expect(screen.getByText("1h")).toBeInTheDocument(); + expect(screen.getByText("24h")).toBeInTheDocument(); + }); + + it("applies custom className", () => { + const { container } = render(); + + const sectionHeading = container.firstChild as HTMLElement; + expect(sectionHeading).toHaveClass("custom-class"); + }); +}); diff --git a/client/src/protoFleet/features/dashboard/components/SectionHeading/SectionHeading.tsx b/client/src/protoFleet/features/dashboard/components/SectionHeading/SectionHeading.tsx new file mode 100644 index 000000000..42012b201 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/SectionHeading/SectionHeading.tsx @@ -0,0 +1,19 @@ +import { ReactNode } from "react"; +import clsx from "clsx"; + +type SectionHeadingProps = { + heading: string; + children?: ReactNode; + className?: string; +}; + +const SectionHeading = ({ heading, children, className }: SectionHeadingProps) => { + return ( +
+
{heading}
+ {children ?
{children}
: null} +
+ ); +}; + +export default SectionHeading; diff --git a/client/src/protoFleet/features/dashboard/components/SectionHeading/index.ts b/client/src/protoFleet/features/dashboard/components/SectionHeading/index.ts new file mode 100644 index 000000000..d9b154156 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/SectionHeading/index.ts @@ -0,0 +1 @@ +export { default } from "./SectionHeading"; diff --git a/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/SegmentedMetricPanel.stories.tsx b/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/SegmentedMetricPanel.stories.tsx new file mode 100644 index 000000000..7beaddfee --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/SegmentedMetricPanel.stories.tsx @@ -0,0 +1,295 @@ +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import type { Meta, StoryObj } from "@storybook/react"; +import { SegmentedMetricPanel } from "./SegmentedMetricPanel"; +import type { SegmentConfig } from "./types"; +import type { TemperatureStatusCount } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { Triangle } from "@/shared/assets/icons"; +import { fleetDurations } from "@/shared/components/DurationSelector"; + +const meta = { + title: "Proto Fleet/Dashboard/SegmentedMetricPanel", + component: SegmentedMetricPanel, + tags: ["autodocs"], + parameters: { + layout: "padded", + docs: { + description: { + component: + "A panel component that combines ChartWidget with a SegmentedBarChart and current status breakdown. Supports granular intervals for short durations and multi-chart display for longer periods.", + }, + }, + }, + argTypes: { + title: { + control: "text", + description: "The title displayed at the top of the panel", + }, + headline: { + control: "text", + description: "Summary headline shown below the title", + }, + chartData: { + control: "object", + description: "Temperature status count data from the API", + }, + segmentConfig: { + control: "object", + description: "Configuration for each segment (color, label, etc.)", + }, + duration: { + control: "select", + options: fleetDurations, + description: "Time duration for the chart display", + }, + }, + decorators: [ + (Story) => ( +
+
+ +
+
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Helper to create timestamp from date +const createTimestamp = (date: Date): Timestamp => { + const millis = date.getTime(); + const seconds = Math.floor(millis / 1000); + const nanos = (millis % 1000) * 1000000; + return { + seconds: seconds as any, // Protobuf expects bigint but for display we use number + nanos, + } as Timestamp; +}; + +// Generate highly granular mock data (every minute) +const generateGranularData = ( + hours: number, + basePattern?: { + cold?: number; + ok?: number; + hot?: number; + critical?: number; + }, +): TemperatureStatusCount[] => { + const data: TemperatureStatusCount[] = []; + const now = new Date(); + const minutesTotal = hours * 60; + + // Default pattern if not provided + const pattern = { + cold: 2, + ok: 180, + hot: 15, + critical: 3, + ...basePattern, // Override with provided values + }; + + for (let i = 0; i < minutesTotal; i++) { + const time = new Date(now.getTime() - (minutesTotal - i - 1) * 60 * 1000); + + // Add some variation to make it realistic + const variation = Math.sin(i / 10) * 0.1; // ±10% variation + + data.push({ + timestamp: createTimestamp(time), + coldCount: Math.max(0, Math.floor(pattern.cold + pattern.cold * variation)), + okCount: Math.max(0, Math.floor(pattern.ok + pattern.ok * variation)), + hotCount: Math.max(0, Math.floor(pattern.hot + pattern.hot * variation)), + criticalCount: Math.max(0, Math.floor(pattern.critical + pattern.critical * variation)), + } as TemperatureStatusCount); + } + + return data; +}; + +// Temperature segment configuration +const temperatureSegmentConfig: SegmentConfig = { + cold: { + color: "var(--color-intent-info-fill)", + label: "Cold", + displayInBreakdown: true, + index: 2, // Third in order + }, + ok: { + color: "var(--color-intent-info-20)", + label: "Healthy", + displayInBreakdown: true, + index: 3, // Fourth in order + showButton: false, + percentageLabel: "Within optimal range", // Custom label for normal temperature + }, + hot: { + color: "var(--color-intent-warning-fill)", + label: "Hot", + displayInBreakdown: true, + index: 1, // Second in order + }, + critical: { + color: "var(--color-intent-critical-fill)", + label: "Critical", + displayInBreakdown: true, + icon: , + index: 0, // First in order + buttonVariant: "primary", // Use primary button for critical items + }, +}; + +// 1 Hour Duration - Shows 5-minute intervals +export const OneHourDuration: Story = { + args: { + title: "Temperature", + headline: "8.5% outside safe range", + chartData: generateGranularData(1.5), // Generate 1.5 hours of minute-level data + segmentConfig: temperatureSegmentConfig, + duration: "1h", + }, +}; + +// 12 Hour Duration - Shows hourly intervals +export const TwelveHourDuration: Story = { + args: { + title: "Temperature", + headline: "9.0% outside safe range", + chartData: generateGranularData(13), // Generate 13 hours of minute-level data + segmentConfig: temperatureSegmentConfig, + duration: "24h", + }, +}; + +// 24 Hour Duration - Shows 2-hour intervals +export const TwentyFourHourDuration: Story = { + args: { + title: "Temperature", + headline: "10.0% outside safe range", + chartData: generateGranularData(25), // Generate 25 hours of minute-level data + segmentConfig: temperatureSegmentConfig, + duration: "24h", + }, +}; + +// 7 Day Duration - Multiple charts with 2 bars per day +export const SevenDayDuration: Story = { + args: { + title: "Temperature", + headline: "9.5% outside safe range", + chartData: generateGranularData(169), // Generate just over 7 days of minute-level data + segmentConfig: temperatureSegmentConfig, + duration: "7d", + }, +}; + +// 30 Day Duration - Daily bars over a month +export const ThirtyDayDuration: Story = { + args: { + title: "Temperature", + headline: "10.5% outside safe range", + chartData: generateGranularData(24 * 31), // Generate just over 30 days of minute-level data + segmentConfig: temperatureSegmentConfig, + duration: "30d", + }, +}; + +// With percentage display enabled +export const WithPercentages: Story = { + args: { + title: "Temperature", + headline: "11.0% outside safe range", + chartData: generateGranularData(24, { + cold: 5, + ok: 170, + hot: 20, + critical: 5, + }), + segmentConfig: temperatureSegmentConfig, + duration: "24h", + }, +}; + +// Edge case: Very few miners +export const FewMiners: Story = { + args: { + title: "Temperature", + headline: "25.0% outside safe range", + chartData: generateGranularData(12, { + cold: 1, + ok: 6, + hot: 2, + critical: 1, + }), + segmentConfig: temperatureSegmentConfig, + duration: "24h", + }, +}; + +// Edge case: All miners in one category +export const AllNormal: Story = { + args: { + title: "Temperature", + headline: "0.0% outside safe range", + chartData: generateGranularData(6, { + cold: 0, + ok: 200, + hot: 0, + critical: 0, + }), + segmentConfig: temperatureSegmentConfig, + duration: "24h", + }, +}; + +// Edge case: No data +export const NoData: Story = { + args: { + title: "Temperature", + headline: "No data", + chartData: [], + segmentConfig: temperatureSegmentConfig, + duration: "24h", + }, +}; + +// Custom segment configuration (different use case) +const uptimeSegmentConfig: SegmentConfig = { + offline: { + color: "var(--color-intent-critical-fill)", + label: "Offline", + displayInBreakdown: true, + }, + sleeping: { + color: "var(--color-intent-warning-fill)", + label: "Sleeping", + displayInBreakdown: true, + }, + broken: { + color: "var(--color-intent-info-fill)", + label: "Broken", + displayInBreakdown: true, + }, + hashing: { + color: "var(--color-intent-success-fill)", + label: "Hashing", + displayInBreakdown: true, + }, +}; + +// Alternative use case: Uptime monitoring +export const UptimeMonitoring: Story = { + args: { + title: "Uptime", + headline: "5.0% not hashing", + chartData: generateGranularData(24, { + cold: 5, // Using cold for offline + ok: 190, // Using ok for hashing + hot: 3, // Using hot for sleeping + critical: 2, // Using critical for broken + }), + segmentConfig: uptimeSegmentConfig, + duration: "24h", + }, +}; diff --git a/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/SegmentedMetricPanel.tsx b/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/SegmentedMetricPanel.tsx new file mode 100644 index 000000000..bd4f03935 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/SegmentedMetricPanel.tsx @@ -0,0 +1,176 @@ +import { useMemo } from "react"; + +import { DEFAULT_CHART_HEIGHT } from "./constants"; +import type { SegmentedMetricPanelProps } from "./types"; +import { durationToHours, getCurrentBreakdown, processMultiDayChartData } from "./utils"; +import ChartWidget from "@/protoFleet/features/dashboard/components/ChartWidget"; +import { StatusBreakdownPanel } from "@/protoFleet/features/dashboard/components/StatusBreakdownPanel"; +import SegmentedBarChart from "@/shared/components/SegmentedBarChart"; + +// Constants for bar chart display +const MULTI_DAY_BAR_WIDTH = { + desktop: 8, + laptop: 6, + tablet: 8, + phone: 6, +}; + +// Duration thresholds (in hours) +const ONE_DAY_IN_HOURS = 24; +const TWO_DAYS_IN_HOURS = 48; +const TEN_DAYS_IN_HOURS = 240; + +// X-axis tick intervals based on duration +const TICK_INTERVAL_SINGLE_DAY = 1; +const TICK_INTERVAL_SHORT_MULTI_DAY = 3; +const TICK_INTERVAL_MEDIUM_MULTI_DAY = 2; +const TICK_INTERVAL_LONG_MULTI_DAY = 4; + +/** + * Determines the x-axis tick interval based on duration. + * Shorter durations show more ticks, longer durations show fewer to prevent overlap. + */ +const getXAxisTickInterval = (hours: number, isMultiDay: boolean): number => { + if (!isMultiDay) { + return TICK_INTERVAL_SINGLE_DAY; + } + if (hours <= TWO_DAYS_IN_HOURS) { + return TICK_INTERVAL_SHORT_MULTI_DAY; + } + if (hours <= TEN_DAYS_IN_HOURS) { + return TICK_INTERVAL_MEDIUM_MULTI_DAY; + } + return TICK_INTERVAL_LONG_MULTI_DAY; +}; + +/** + * Determines whether to use date format (e.g., "1/15") for x-axis ticks. + * Use date format for multi-day durations where bars represent multiple hours. + */ +const shouldUseDateFormat = (hours: number): boolean => { + return hours > ONE_DAY_IN_HOURS; +}; + +export const SegmentedMetricPanel = ({ + title, + headline, + headlineGenerator, + chartData, + segmentConfig, + duration, + className, +}: SegmentedMetricPanelProps) => { + // Process the chart data - returns array of arrays for multi-day views + const processedChartData = useMemo( + () => processMultiDayChartData(chartData, duration, segmentConfig), + [chartData, duration, segmentConfig], + ); + + // Calculate current breakdown from processed chart data (shares logic with chart) + const currentBreakdown = useMemo( + () => getCurrentBreakdown(processedChartData, segmentConfig), + [processedChartData, segmentConfig], + ); + + // Extract segment keys from config + const segmentKeys = useMemo(() => Object.keys(segmentConfig), [segmentConfig]); + + // Build color map from config + const colorMap = useMemo( + () => + Object.entries(segmentConfig).reduce( + (acc, [key, config]) => { + acc[key] = config.color; + return acc; + }, + {} as Record, + ), + [segmentConfig], + ); + + // Determine if we're showing multiple charts + const hours = durationToHours(duration); + const isMultiDay = hours > 24; + + // Calculate bar width for multi-chart layout + const barWidth = useMemo(() => { + if (!isMultiDay) return undefined; + return MULTI_DAY_BAR_WIDTH; + }, [isMultiDay]); + + // Calculate equal chart widths for multi-day view + const chartWidths = useMemo(() => { + if (!isMultiDay) return ["100%"]; + + const numberOfCharts = processedChartData.length; + const chartWidthPercentage = `${100 / numberOfCharts}%`; + return processedChartData.map(() => chartWidthPercentage); + }, [isMultiDay, processedChartData]); + + // Generate headline using the generator function if provided, otherwise use static headline + const computedHeadline = useMemo(() => { + if (headlineGenerator && processedChartData.length > 0) { + return headlineGenerator(processedChartData); + } + return headline || ""; + }, [headlineGenerator, processedChartData, headline]); + + // Check if we have no data + const hasNoData = !chartData || chartData.length === 0; + + const stat = { + label: title, + value: hasNoData ? "No data" : computedHeadline, + }; + + // If no data, render just the ChartWidget without charts or breakdown + if (hasNoData) { + return {null}; + } + + return ( +
+ {/* Left Panel: ChartWidget with SegmentedBarChart(s) */} + +
+ {processedChartData.map((dayData, index) => { + // Use pre-calculated width for this chart + const chartWidth = chartWidths[index]; + + return ( +
+ 1} + useDateFormat={shouldUseDateFormat(hours)} + lastTickOverride={!isMultiDay && hours < 24 ? "live" : undefined} + /> +
+ ); + })} +
+
+ + {/* Right Panel: Current Values Breakdown */} + +
+ ); +}; diff --git a/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/constants.ts b/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/constants.ts new file mode 100644 index 000000000..d2461fae9 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/constants.ts @@ -0,0 +1,14 @@ +// Default color mappings for common status types +export const STATUS_COLORS = { + // Uptime statuses + hashing: "--color-intent-success-fill", + notHashing: "--color-text-primary-20", + + // Temperature statuses + normal: "--color-intent-success-fill", + hot: "--color-intent-warning-fill", + critical: "--color-intent-critical-fill", + cold: "--color-intent-info-fill", +} as const; + +export const DEFAULT_CHART_HEIGHT = 284; diff --git a/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/index.ts b/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/index.ts new file mode 100644 index 000000000..722c82ee1 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/index.ts @@ -0,0 +1,3 @@ +export { SegmentedMetricPanel } from "./SegmentedMetricPanel"; +export type { SegmentedMetricPanelProps, SegmentConfig } from "./types"; +export { STATUS_COLORS } from "./constants"; diff --git a/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/types.ts b/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/types.ts new file mode 100644 index 000000000..2665748f7 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/types.ts @@ -0,0 +1,40 @@ +import type { ReactNode } from "react"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import type { ButtonVariant } from "@/shared/components/Button"; +import type { FleetDuration } from "@/shared/components/DurationSelector"; + +export interface SegmentedBarChartData { + datetime: number; + [key: string]: number; +} + +export interface SegmentConfig { + [key: string]: { + color: string; + label: string; + icon?: ReactNode; + displayInBreakdown?: boolean; + index?: number; // Controls the order segments appear in the breakdown + buttonVariant?: ButtonVariant; // Button variant for the segment + percentageLabel?: string; // Custom label to show instead of "n% of fleet" + showButton?: boolean; // Whether to show the button with miner count (defaults to true) + onClick?: () => void; // Optional click handler for the segment button + }; +} + +// Generic status count with timestamp +// Supports Protobuf-generated types with $typeName and $unknown properties +export interface StatusCount { + timestamp?: Timestamp; + [key: string]: number | Timestamp | string | unknown[] | undefined; +} + +export interface SegmentedMetricPanelProps { + title: string; + headline?: string; // Optional static headline + headlineGenerator?: (processedData: SegmentedBarChartData[][]) => string; // Optional dynamic headline generator + chartData: StatusCount[]; + segmentConfig: SegmentConfig; + duration: FleetDuration; + className?: string; +} diff --git a/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/utils.test.ts b/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/utils.test.ts new file mode 100644 index 000000000..505886477 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/utils.test.ts @@ -0,0 +1,657 @@ +import { describe, expect, it } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import { TimestampSchema } from "@bufbuild/protobuf/wkt"; +import type { SegmentConfig, SegmentedBarChartData, StatusCount } from "./types"; +import { getCurrentBreakdown, processChartData, processMultiDayChartData } from "./utils"; + +describe("getCurrentBreakdown", () => { + const mockSegmentConfig: SegmentConfig = { + hashing: { + color: "var(--color-text-primary)", + label: "Hashing", + displayInBreakdown: true, + showButton: false, + index: 1, + }, + notHashing: { + color: "var(--color-core-primary-10)", + label: "Not hashing", + displayInBreakdown: true, + showButton: true, + buttonVariant: "secondary", + index: 0, + }, + }; + + it("returns empty array when processedChartData is empty", () => { + const result = getCurrentBreakdown([], mockSegmentConfig); + expect(result).toEqual([]); + }); + + it("returns empty array when processedChartData has empty charts", () => { + const result = getCurrentBreakdown([[]], mockSegmentConfig); + expect(result).toEqual([]); + }); + + it("calculates breakdown from last bar of single-day chart", () => { + const processedData: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now() - 10000, + hashing: 5, + notHashing: 0, + }, + { + datetime: Date.now(), + hashing: 3, + notHashing: 2, + }, + ], + ]; + + const result = getCurrentBreakdown(processedData, mockSegmentConfig); + + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ + key: "notHashing", + label: "Not hashing", + count: 2, + percentage: 40, + }); + expect(result[1]).toMatchObject({ + key: "hashing", + label: "Hashing", + count: 3, + percentage: 60, + }); + }); + + it("calculates breakdown from last bar of last day in multi-day chart", () => { + const processedData: SegmentedBarChartData[][] = [ + // Day 1 + [ + { + datetime: Date.now() - 20000, + hashing: 5, + notHashing: 0, + }, + ], + // Day 2 + [ + { + datetime: Date.now() - 10000, + hashing: 4, + notHashing: 1, + }, + ], + // Day 3 (most recent) + [ + { + datetime: Date.now(), + hashing: 2, + notHashing: 3, + }, + ], + ]; + + const result = getCurrentBreakdown(processedData, mockSegmentConfig); + + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ + key: "notHashing", + count: 3, + percentage: 60, + }); + expect(result[1]).toMatchObject({ + key: "hashing", + count: 2, + percentage: 40, + }); + }); + + it("handles zero total count", () => { + const processedData: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 0, + notHashing: 0, + }, + ], + ]; + + const result = getCurrentBreakdown(processedData, mockSegmentConfig); + + expect(result).toHaveLength(2); + expect(result[0].percentage).toBe(0); + expect(result[1].percentage).toBe(0); + }); + + it("rounds percentages correctly", () => { + const processedData: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 6, + notHashing: 1, // 1/7 = 14.28% + }, + ], + ]; + + const result = getCurrentBreakdown(processedData, mockSegmentConfig); + + const notHashingSegment = result.find((s) => s.key === "notHashing"); + expect(notHashingSegment?.percentage).toBe(14); + }); + + it("handles undefined segment values", () => { + const processedData: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 5, + // notHashing is undefined + }, + ], + ]; + + const result = getCurrentBreakdown(processedData, mockSegmentConfig); + + const notHashingSegment = result.find((s) => s.key === "notHashing"); + expect(notHashingSegment?.count).toBe(0); + }); + + it("uses custom percentage label when provided", () => { + const customConfig: SegmentConfig = { + ...mockSegmentConfig, + notHashing: { + ...mockSegmentConfig.notHashing, + percentageLabel: "Custom label", + }, + }; + + const processedData: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 3, + notHashing: 2, + }, + ], + ]; + + const result = getCurrentBreakdown(processedData, customConfig); + + const notHashingSegment = result.find((s) => s.key === "notHashing"); + expect(notHashingSegment?.percentageLabel).toBe("Custom label"); + }); + + it("sorts breakdown by index", () => { + const processedData: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 3, + notHashing: 2, + }, + ], + ]; + + const result = getCurrentBreakdown(processedData, mockSegmentConfig); + + // notHashing has index 0, hashing has index 1 + expect(result[0].key).toBe("notHashing"); + expect(result[1].key).toBe("hashing"); + }); + + it("filters out segments with displayInBreakdown = false", () => { + const configWithHidden: SegmentConfig = { + ...mockSegmentConfig, + hashing: { + ...mockSegmentConfig.hashing, + displayInBreakdown: false, + }, + }; + + const processedData: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 3, + notHashing: 2, + }, + ], + ]; + + const result = getCurrentBreakdown(processedData, configWithHidden); + + expect(result).toHaveLength(1); + expect(result[0].key).toBe("notHashing"); + }); + + describe("Edge case: Legend uses processed chart data, ensuring consistency", () => { + it("uses the exact data from the last processed chart bar", () => { + // This test verifies the fix for zero-value edge case: + // Legend should use the same data as the chart's last bar, + // not independently process raw data which could have newer timestamps + + // Create processed chart data directly (as if from processMultiDayChartData) + const processedData: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now() - 10000, + hashing: 5, + notHashing: 0, + }, + { + datetime: Date.now() - 5000, + hashing: 3, + notHashing: 2, // This is what the chart's last bar shows + }, + ], + ]; + + // Get breakdown - should use exact values from last bar + const result = getCurrentBreakdown(processedData, mockSegmentConfig); + + const notHashingSegment = result.find((s) => s.key === "notHashing"); + const hashingSegment = result.find((s) => s.key === "hashing"); + + // Verify it matches the last bar exactly + expect(notHashingSegment?.count).toBe(2); + expect(hashingSegment?.count).toBe(3); + }); + + it("always matches chart's last bar in multi-day view", () => { + // Multi-day scenario: Legend should use the last bar of the last day + const processedData: SegmentedBarChartData[][] = [ + // Day 1 + [ + { + datetime: Date.now() - 48 * 60 * 60 * 1000, + hashing: 10, + notHashing: 0, + }, + ], + // Day 2 (most recent day) + [ + { + datetime: Date.now() - 12 * 60 * 60 * 1000, + hashing: 7, + notHashing: 1, + }, + { + datetime: Date.now(), // Last bar of last day + hashing: 4, + notHashing: 3, + }, + ], + ]; + + const result = getCurrentBreakdown(processedData, mockSegmentConfig); + + const notHashingSegment = result.find((s) => s.key === "notHashing"); + const hashingSegment = result.find((s) => s.key === "hashing"); + + // Should match the last bar (4 hashing, 3 not hashing) + // Not day 1 data (10, 0) or first bar of day 2 (7, 1) + expect(hashingSegment?.count).toBe(4); + expect(notHashingSegment?.count).toBe(3); + }); + + it("breakdown and chart are guaranteed to be in sync", () => { + // The key guarantee: since getCurrentBreakdown takes processed chart data, + // it's IMPOSSIBLE for them to be out of sync + const processedData: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 100, + notHashing: 50, + }, + ], + ]; + + // Get the last bar that the chart displays + const lastChart = processedData[processedData.length - 1]; + const lastBar = lastChart[lastChart.length - 1]; + + // Get the breakdown + const result = getCurrentBreakdown(processedData, mockSegmentConfig); + + // They MUST match because breakdown uses the same processed data + const notHashingSegment = result.find((s) => s.key === "notHashing"); + const hashingSegment = result.find((s) => s.key === "hashing"); + + expect(hashingSegment?.count).toBe(lastBar.hashing); + expect(notHashingSegment?.count).toBe(lastBar.notHashing); + }); + }); +}); + +describe("processChartData - Last interval uses latest data", () => { + const segmentConfig: SegmentConfig = { + cold: { + color: "var(--color-core-blue-60)", + label: "Cold", + displayInBreakdown: true, + showButton: false, + index: 0, + }, + ok: { + color: "var(--color-core-green-60)", + label: "OK", + displayInBreakdown: true, + showButton: false, + index: 1, + }, + hot: { + color: "var(--color-core-orange-60)", + label: "Hot", + displayInBreakdown: true, + showButton: false, + index: 2, + }, + critical: { + color: "var(--color-core-red-60)", + label: "Critical", + displayInBreakdown: true, + showButton: false, + index: 3, + }, + }; + + it("should use most recent data point for last interval even if after boundary", () => { + const now = Date.now(); + const data: StatusCount[] = [ + { + timestamp: create(TimestampSchema, { + seconds: BigInt(Math.floor((now - 10 * 60 * 1000) / 1000)), + nanos: 0, + }), + coldCount: 5, + okCount: 10, + hotCount: 2, + criticalCount: 0, + }, // 10 min ago + { + timestamp: create(TimestampSchema, { + seconds: BigInt(Math.floor((now - 2 * 60 * 1000) / 1000)), + nanos: 0, + }), + coldCount: 8, + okCount: 12, + hotCount: 3, + criticalCount: 1, + }, // 2 min ago (latest) + ]; + + const result = processChartData(data, "12h", segmentConfig); + const lastBar = result[result.length - 1]; + + // Last bar should use latest data (coldCount: 8), not interval-bounded data + expect(lastBar.cold).toBe(8); + expect(lastBar.ok).toBe(12); + expect(lastBar.hot).toBe(3); + expect(lastBar.critical).toBe(1); + }); + + it("should use interval-bounded data for non-last intervals", () => { + const now = Date.now(); + const data: StatusCount[] = [ + { + timestamp: create(TimestampSchema, { + seconds: BigInt(Math.floor((now - 10 * 60 * 1000) / 1000)), + nanos: 0, + }), + coldCount: 5, + okCount: 10, + hotCount: 2, + criticalCount: 0, + }, // 10 min ago + { + timestamp: create(TimestampSchema, { + seconds: BigInt(Math.floor((now - 2 * 60 * 1000) / 1000)), + nanos: 0, + }), + coldCount: 8, + okCount: 12, + hotCount: 3, + criticalCount: 1, + }, // 2 min ago (latest) + ]; + + const result = processChartData(data, "12h", segmentConfig); + + // First bars should use interval-bounded data, not latest + // Verify that at least one non-last bar exists and doesn't use latest data + expect(result.length).toBeGreaterThan(1); + + // The first bar should use the first data point (or null if no data before that interval) + const firstBar = result[0]; + // First bar might be 0 if no data before that interval + // But it definitely shouldn't have the latest values (8, 12, 3, 1) + const isUsingLatestData = + firstBar.cold === 8 && firstBar.ok === 12 && firstBar.hot === 3 && firstBar.critical === 1; + expect(isUsingLatestData).toBe(false); + }); + + it("should handle empty data gracefully", () => { + const data: StatusCount[] = []; + + const result = processChartData(data, "12h", segmentConfig); + + // Should return 12 intervals with all zeros + expect(result.length).toBe(12); + result.forEach((bar) => { + expect(bar.cold).toBe(0); + expect(bar.ok).toBe(0); + expect(bar.hot).toBe(0); + expect(bar.critical).toBe(0); + }); + }); + + it("should handle single data point correctly", () => { + const now = Date.now(); + const data: StatusCount[] = [ + { + timestamp: create(TimestampSchema, { + seconds: BigInt(Math.floor((now - 5 * 60 * 1000) / 1000)), + nanos: 0, + }), + coldCount: 3, + okCount: 7, + hotCount: 1, + criticalCount: 0, + }, + ]; + + const result = processChartData(data, "12h", segmentConfig); + const lastBar = result[result.length - 1]; + + // Last bar should use the only data point + expect(lastBar.cold).toBe(3); + expect(lastBar.ok).toBe(7); + expect(lastBar.hot).toBe(1); + expect(lastBar.critical).toBe(0); + }); +}); + +describe("processMultiDayChartData - Last interval uses latest data", () => { + const segmentConfig: SegmentConfig = { + cold: { + color: "var(--color-core-blue-60)", + label: "Cold", + displayInBreakdown: true, + showButton: false, + index: 0, + }, + ok: { + color: "var(--color-core-green-60)", + label: "OK", + displayInBreakdown: true, + showButton: false, + index: 1, + }, + hot: { + color: "var(--color-core-orange-60)", + label: "Hot", + displayInBreakdown: true, + showButton: false, + index: 2, + }, + critical: { + color: "var(--color-core-red-60)", + label: "Critical", + displayInBreakdown: true, + showButton: false, + index: 3, + }, + }; + + it("should use most recent data point for last interval of last day", () => { + const now = Date.now(); + const data: StatusCount[] = [ + { + timestamp: create(TimestampSchema, { + seconds: BigInt(Math.floor((now - 48 * 60 * 60 * 1000) / 1000)), + nanos: 0, + }), + coldCount: 3, + okCount: 8, + hotCount: 1, + criticalCount: 0, + }, // 48 hours ago + { + timestamp: create(TimestampSchema, { + seconds: BigInt(Math.floor((now - 2 * 60 * 1000) / 1000)), + nanos: 0, + }), + coldCount: 8, + okCount: 12, + hotCount: 3, + criticalCount: 1, + }, // 2 min ago (latest) + ]; + + const result = processMultiDayChartData(data, "48h", segmentConfig); + + // Get the last chart (last day) + const lastDay = result[result.length - 1]; + const lastBar = lastDay[lastDay.length - 1]; + + // Last bar of last day should use latest data + expect(lastBar.cold).toBe(8); + expect(lastBar.ok).toBe(12); + expect(lastBar.hot).toBe(3); + expect(lastBar.critical).toBe(1); + }); + + it("should use interval-bounded data for non-last intervals", () => { + const now = Date.now(); + const data: StatusCount[] = [ + { + timestamp: create(TimestampSchema, { + seconds: BigInt(Math.floor((now - 48 * 60 * 60 * 1000) / 1000)), + nanos: 0, + }), + coldCount: 2, + okCount: 5, + hotCount: 0, + criticalCount: 0, + }, // 48 hours ago + { + timestamp: create(TimestampSchema, { + seconds: BigInt(Math.floor((now - 2 * 60 * 1000) / 1000)), + nanos: 0, + }), + coldCount: 8, + okCount: 12, + hotCount: 3, + criticalCount: 1, + }, // 2 min ago (latest) + ]; + + const result = processMultiDayChartData(data, "48h", segmentConfig); + + // First day's bars should not all use the latest data + const firstDay = result[0]; + const firstBar = firstDay[0]; + + // First bar should not have latest values + const isUsingLatestData = + firstBar.cold === 8 && firstBar.ok === 12 && firstBar.hot === 3 && firstBar.critical === 1; + expect(isUsingLatestData).toBe(false); + }); + + it("should handle empty data gracefully", () => { + const data: StatusCount[] = []; + + const result = processMultiDayChartData(data, "48h", segmentConfig); + + // Should return structured data with zeros + expect(result.length).toBeGreaterThan(0); + result.forEach((day) => { + day.forEach((bar) => { + expect(bar.cold).toBe(0); + expect(bar.ok).toBe(0); + expect(bar.hot).toBe(0); + expect(bar.critical).toBe(0); + }); + }); + }); + + it("should delegate to processChartData for durations <= 24h", () => { + const now = Date.now(); + const data: StatusCount[] = [ + { + timestamp: create(TimestampSchema, { + seconds: BigInt(Math.floor((now - 2 * 60 * 1000) / 1000)), + nanos: 0, + }), + coldCount: 5, + okCount: 10, + hotCount: 2, + criticalCount: 1, + }, + ]; + + const result = processMultiDayChartData(data, "12h", segmentConfig); + + // Should return single-day array + expect(result.length).toBe(1); + const singleDay = result[0]; + const lastBar = singleDay[singleDay.length - 1]; + + // Last bar should use latest data (same as processChartData) + expect(lastBar.cold).toBe(5); + expect(lastBar.ok).toBe(10); + }); + + it("should handle single data point correctly across multiple days", () => { + const now = Date.now(); + const data: StatusCount[] = [ + { + timestamp: create(TimestampSchema, { + seconds: BigInt(Math.floor((now - 5 * 60 * 1000) / 1000)), + nanos: 0, + }), + coldCount: 3, + okCount: 7, + hotCount: 1, + criticalCount: 0, + }, + ]; + + const result = processMultiDayChartData(data, "48h", segmentConfig); + + // Last bar of last day should use the only data point + const lastDay = result[result.length - 1]; + const lastBar = lastDay[lastDay.length - 1]; + + expect(lastBar.cold).toBe(3); + expect(lastBar.ok).toBe(7); + expect(lastBar.hot).toBe(1); + expect(lastBar.critical).toBe(0); + }); +}); diff --git a/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/utils.ts b/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/utils.ts new file mode 100644 index 000000000..f3099de23 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/utils.ts @@ -0,0 +1,383 @@ +import { timestampMs } from "@bufbuild/protobuf/wkt"; +import type { SegmentConfig, SegmentedBarChartData, StatusCount } from "./types"; + +/** + * Convert segment key to field name (e.g., "cold" -> "coldCount", "notHashing" -> "notHashingCount") + */ +const segmentKeyToFieldName = (key: string): string => { + return `${key}Count`; +}; + +/** + * Get count value from data point for a given segment key + */ +const getCountForSegment = (dataPoint: StatusCount | null, segmentKey: string): number => { + if (!dataPoint) return 0; + const fieldName = segmentKeyToFieldName(segmentKey); + const value = dataPoint[fieldName]; + return typeof value === "number" ? value : 0; +}; + +/** + * Convert duration string to hours + */ +export const durationToHours = (duration: string): number => { + const value = parseInt(duration.slice(0, -1)); + const unit = duration.slice(-1); + + switch (unit) { + case "h": + return value; + case "d": + return value * 24; + case "y": + return value * 365 * 24; + default: + return 12; // Default to 12 hours + } +}; + +/** + * Generate timestamps for chart intervals with appropriate granularity + */ +export const getHourlyIntervals = (duration: string): number[] => { + const hours = durationToHours(duration); + const now = new Date(); + const intervals: number[] = []; + + // Always try to show 12 intervals + const intervalCount = 12; + + // Calculate interval in minutes + const totalMinutes = hours * 60; + let minutesPerInterval = totalMinutes / intervalCount; + + // Round to clean boundaries for better readability + if (minutesPerInterval <= 5) { + minutesPerInterval = 5; + } else if (minutesPerInterval <= 10) { + minutesPerInterval = 10; + } else if (minutesPerInterval <= 15) { + minutesPerInterval = 15; + } else if (minutesPerInterval <= 30) { + minutesPerInterval = 30; + } else if (minutesPerInterval <= 60) { + minutesPerInterval = 60; + } else if (minutesPerInterval <= 120) { + minutesPerInterval = 120; + } else if (minutesPerInterval <= 240) { + minutesPerInterval = 240; + } else if (minutesPerInterval <= 600) { + minutesPerInterval = 600; + } else { + // For very long durations, round to nearest hour + minutesPerInterval = Math.ceil(minutesPerInterval / 60) * 60; + } + + // Round current time UP to the next interval boundary + const endTime = new Date(now); + endTime.setSeconds(0, 0); + const currentMinutes = endTime.getMinutes(); + const roundedMinutes = Math.ceil(currentMinutes / minutesPerInterval) * minutesPerInterval; + + // If we rounded up to 60 minutes, move to the next hour + if (roundedMinutes === 60) { + endTime.setHours(endTime.getHours() + 1); + endTime.setMinutes(0); + } else { + endTime.setMinutes(roundedMinutes); + } + + // Calculate the start time (going back from the rounded end time) + const startTime = endTime.getTime() - totalMinutes * 60 * 1000; + + // Generate intervals from start to end + for (let i = 0; i < intervalCount; i++) { + const intervalTime = startTime + i * minutesPerInterval * 60 * 1000; + intervals.push(intervalTime); + } + + return intervals; +}; + +/** + * Find the data point immediately before or at a given timestamp + */ +export const findDataPointBefore = (data: StatusCount[], timestamp: number): StatusCount | null => { + if (!data || data.length === 0) return null; + + // Find the last data point that is before or at the timestamp + let bestPoint: StatusCount | null = null; + + for (const point of data) { + const pointTime = point.timestamp ? timestampMs(point.timestamp) : 0; + + if (pointTime <= timestamp) { + bestPoint = point; + } else { + break; // Data is sorted, so we can stop once we pass the timestamp + } + } + + return bestPoint; +}; + +/** + * Process raw status counts into chart data + */ +export const processChartData = ( + data: StatusCount[], + duration: string, + segmentConfig: SegmentConfig, +): SegmentedBarChartData[] => { + const hourlyIntervals = getHourlyIntervals(duration); + const processedData: SegmentedBarChartData[] = []; + const segmentKeys = Object.keys(segmentConfig); + + // If no data, return empty data points for all intervals + if (!data || data.length === 0) { + return hourlyIntervals.map((interval) => { + const chartPoint: SegmentedBarChartData = { datetime: interval }; + segmentKeys.forEach((key) => { + chartPoint[key] = 0; + }); + return chartPoint; + }); + } + + // Sort data by timestamp + const sortedData = [...data].sort((a, b) => { + const timeA = a.timestamp ? timestampMs(a.timestamp) : 0; + const timeB = b.timestamp ? timestampMs(b.timestamp) : 0; + return timeA - timeB; + }); + + // For each hourly interval, find the appropriate data point + for (let i = 0; i < hourlyIntervals.length; i++) { + const interval = hourlyIntervals[i]; + const isLastInterval = i === hourlyIntervals.length - 1; + + // For last interval, use absolute latest data; for others, use data at interval + const dataPoint = isLastInterval + ? sortedData[sortedData.length - 1] // Latest data + : findDataPointBefore(sortedData, interval); // Data at interval boundary + + // Always create a chart point for every interval + const chartPoint: SegmentedBarChartData = { datetime: interval }; + segmentKeys.forEach((key) => { + chartPoint[key] = getCountForSegment(dataPoint, key); + }); + processedData.push(chartPoint); + } + + return processedData; +}; + +/** + * Bucketing configuration based on duration for preventing bar overlap + */ +interface BucketConfig { + hoursPerBucket: number; +} + +/** + * Determine bucket size based on duration to prevent bar overlap. + * Target bar counts: + * - 7d: 14 bars (2 bars/day = 12h buckets) + * - 30d: 30 bars (1 bar/day = 24h buckets) + * - 90d: ~13 bars (weekly = 168h buckets) + * - 1y: ~26 bars (bi-weekly = 336h buckets) + */ +const getBucketConfig = (days: number): BucketConfig => { + if (days <= 3) { + // Up to 3d: 6 bars per day (4h buckets) + return { hoursPerBucket: 4 }; + } else if (days <= 10) { + // Up to 10d: 2 bars per day (12h buckets) + return { hoursPerBucket: 12 }; + } else if (days <= 30) { + // 30d: 1 bar per day (24h buckets) + return { hoursPerBucket: 24 }; + } else if (days <= 90) { + // 90d: Weekly buckets (168h = 7 days) + return { hoursPerBucket: 24 * 7 }; + } else { + // 1y+: Bi-weekly buckets (336h = 14 days) + return { hoursPerBucket: 24 * 14 }; + } +}; + +/** + * Generate intervals for multi-day charts with adaptive bucketing. + * Uses different bucket sizes based on duration to prevent bar overlap. + * Returns a flat array wrapped in an array for compatibility with existing code. + */ +export const getMultiDayIntervals = (duration: string): number[][] => { + const hours = durationToHours(duration); + const now = new Date(); + const currentTime = Date.now(); + + // For durations <= 24h, use single chart (handled by getHourlyIntervals) + if (hours <= 24) { + return [getHourlyIntervals(duration)]; + } + + const days = hours / 24; + const { hoursPerBucket } = getBucketConfig(days); + const minutesPerBar = hoursPerBucket * 60; + + // Calculate start and end times + const endTime = new Date(now); + endTime.setMinutes(0, 0, 0); + const startTime = new Date(endTime.getTime() - hours * 60 * 60 * 1000); + + // Generate all intervals as a single flat list + const intervals: number[] = []; + let currentInterval = new Date(startTime); + currentInterval.setHours(0, 0, 0, 0); // Start at beginning of first bucket + + while (currentInterval <= endTime) { + // Only include intervals that are: + // 1. After or at the start time + // 2. Before or at the end time + // 3. Not in the future + if (currentInterval >= startTime && currentInterval <= endTime && currentInterval.getTime() <= currentTime) { + intervals.push(currentInterval.getTime()); + } + + // Move to next interval + currentInterval = new Date(currentInterval.getTime() + minutesPerBar * 60 * 1000); + } + + // Return as single chart (all bars in one row) + return [intervals]; +}; + +/** + * Process data for multi-day charts + */ +export const processMultiDayChartData = ( + data: StatusCount[], + duration: string, + segmentConfig: SegmentConfig, +): SegmentedBarChartData[][] => { + const hours = durationToHours(duration); + + // For durations <= 24h, use single chart + if (hours <= 24) { + return [processChartData(data, duration, segmentConfig)]; + } + + const dayIntervals = getMultiDayIntervals(duration); + const processedDays: SegmentedBarChartData[][] = []; + const segmentKeys = Object.keys(segmentConfig); + + // If no data, return empty data points for all intervals + if (!data || data.length === 0) { + return dayIntervals.map((intervals) => { + return intervals.map((interval) => { + const chartPoint: SegmentedBarChartData = { datetime: interval }; + segmentKeys.forEach((key) => { + chartPoint[key] = 0; + }); + return chartPoint; + }); + }); + } + + // Sort data by timestamp + const sortedData = data + ? [...data].sort((a, b) => { + const timeA = a.timestamp ? timestampMs(a.timestamp) : 0; + const timeB = b.timestamp ? timestampMs(b.timestamp) : 0; + return timeA - timeB; + }) + : []; + + // Process each day's intervals + for (let dayIndex = 0; dayIndex < dayIntervals.length; dayIndex++) { + const intervals = dayIntervals[dayIndex]; + const dayData: SegmentedBarChartData[] = []; + + for (let i = 0; i < intervals.length; i++) { + const interval = intervals[i]; + const isLastIntervalOfLastDay = + dayIndex === dayIntervals.length - 1 && // Last day + i === intervals.length - 1; // Last interval of that day + + // For last interval of last day, use absolute latest data + const dataPoint = isLastIntervalOfLastDay + ? sortedData[sortedData.length - 1] + : findDataPointBefore(sortedData, interval); + + const chartPoint: SegmentedBarChartData = { datetime: interval }; + segmentKeys.forEach((key) => { + chartPoint[key] = getCountForSegment(dataPoint, key); + }); + dayData.push(chartPoint); + } + + processedDays.push(dayData); + } + + return processedDays; +}; + +/** + * Calculate current breakdown from processed chart data + */ +export const getCurrentBreakdown = (processedChartData: SegmentedBarChartData[][], segmentConfig: SegmentConfig) => { + // Get the last chart (for multi-day view, this is the most recent day) + if (!processedChartData || processedChartData.length === 0) return []; + const lastChart = processedChartData[processedChartData.length - 1]; + + // Get the last data point from the last chart (most recent bar) + if (!lastChart || lastChart.length === 0) return []; + const latestDataPoint = lastChart[lastChart.length - 1]; + + const segmentKeys = Object.keys(segmentConfig); + + // Calculate total from all segment counts + const total = segmentKeys.reduce((sum, key) => sum + ((latestDataPoint[key] as number) || 0), 0); + + const breakdown = []; + + for (const [key, config] of Object.entries(segmentConfig)) { + const count = (latestDataPoint[key] as number) || 0; + + // Include all segments that should be displayed in breakdown, regardless of count + if (config.displayInBreakdown !== false) { + const percentageValue = total > 0 ? Math.round((count / total) * 100) : 0; + const percentageLabel = config.percentageLabel || `${percentageValue}% of fleet`; + + breakdown.push({ + key, + label: config.label, + count, + percentage: percentageValue, + percentageLabel, + color: config.color.replace("var(", "").replace(")", ""), // Remove var() wrapper for inline style + icon: config.icon, + index: config.index ?? 999, // Default to 999 if no index specified + buttonVariant: config.buttonVariant ?? "secondary", // Default to secondary if not specified + showButton: config.showButton !== false, // Default to true if not specified + onClick: config.onClick, // Pass through the onClick handler + }); + } + } + + // Sort by index (lower index appears first) + breakdown.sort((a, b) => a.index - b.index); + + return breakdown; +}; + +/** + * Format miner count with proper singular/plural + */ +export const formatMinerCount = (count: number): string => { + if (count >= 1000) { + return `${(count / 1000).toFixed(1)}k`; + } + return count.toString(); +}; diff --git a/client/src/protoFleet/features/dashboard/components/StatusBreakdownPanel/StatusBreakdownPanel.tsx b/client/src/protoFleet/features/dashboard/components/StatusBreakdownPanel/StatusBreakdownPanel.tsx new file mode 100644 index 000000000..238bf7e3b --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/StatusBreakdownPanel/StatusBreakdownPanel.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from "react"; +import clsx from "clsx"; + +import type { ButtonVariant } from "@/shared/components/Button"; +import Button, { variants } from "@/shared/components/Button"; +import Divider from "@/shared/components/Divider"; + +export interface StatusBreakdownItem { + key: string; + color: string; + label: string; + icon?: ReactNode; + percentageLabel: string; + count: number; + showButton?: boolean; + buttonVariant?: ButtonVariant; + onClick?: () => void; +} + +interface StatusBreakdownPanelProps { + items: StatusBreakdownItem[]; + className?: string; +} + +export const StatusBreakdownPanel = ({ items, className }: StatusBreakdownPanelProps) => { + return ( +
+ {items.map((segment, idx) => ( +
+ {segment.icon ? ( + + {segment.icon} + + ) : ( +
+ )} + +
+ {segment.label} + {segment.percentageLabel} +
+ + {segment.showButton && segment.count > 0 ? ( + + ) : null} + + {idx < items.length - 1 ? : null} +
+ ))} +
+ ); +}; diff --git a/client/src/protoFleet/features/dashboard/components/StatusBreakdownPanel/index.ts b/client/src/protoFleet/features/dashboard/components/StatusBreakdownPanel/index.ts new file mode 100644 index 000000000..e92a4d6c3 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/StatusBreakdownPanel/index.ts @@ -0,0 +1,2 @@ +export { StatusBreakdownPanel } from "./StatusBreakdownPanel"; +export type { StatusBreakdownItem } from "./StatusBreakdownPanel"; diff --git a/client/src/protoFleet/features/dashboard/components/TemperaturePanel/TemperaturePanel.stories.tsx b/client/src/protoFleet/features/dashboard/components/TemperaturePanel/TemperaturePanel.stories.tsx new file mode 100644 index 000000000..10e16b5cc --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/TemperaturePanel/TemperaturePanel.stories.tsx @@ -0,0 +1,215 @@ +import { useMemo } from "react"; +import { create } from "@bufbuild/protobuf"; +import type { Meta, StoryObj } from "@storybook/react"; +import { TemperaturePanel } from "./TemperaturePanel"; +import { + type TemperatureStatusCount, + TemperatureStatusCountSchema, +} from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { durationToHours } from "@/protoFleet/features/dashboard/components/SegmentedMetricPanel/utils"; +import { type FleetDuration, fleetDurations } from "@/shared/components/DurationSelector"; + +// Helper to create mock temperature status counts +const createMockTemperatureStatusCount = ( + timestampSeconds: number, + coldCount: number, + okCount: number, + hotCount: number, + criticalCount: number, +): TemperatureStatusCount => { + return create(TemperatureStatusCountSchema, { + timestamp: { + seconds: BigInt(timestampSeconds), + nanos: 0, + }, + coldCount, + okCount, + hotCount, + criticalCount, + }); +}; + +// Mock TemperaturePanel component for Storybook +interface MockTemperaturePanelProps { + duration: FleetDuration; + coldCount: number; + okCount: number; + hotCount: number; + criticalCount: number; + isLoading?: boolean; // Used to set temperatureStatusCounts to undefined +} + +function MockTemperaturePanel({ + duration, + coldCount, + okCount, + hotCount, + criticalCount, + isLoading = false, +}: MockTemperaturePanelProps) { + // Generate multiple data points across the time range + const temperatureStatusCounts = useMemo(() => { + const durationHours = durationToHours(duration); + const intervalCount = 12; + const intervalHours = durationHours / intervalCount; + + const counts: TemperatureStatusCount[] = []; + // eslint-disable-next-line react-hooks/purity + const now = Math.floor(Date.now() / 1000); + + // Create data points for each interval + for (let i = 0; i < intervalCount; i++) { + const hoursAgo = durationHours - i * intervalHours; + const timestampSeconds = now - Math.floor(hoursAgo * 3600); + + // For the most recent bar, use the exact props + // For historical bars, show all OK temps + const isLatestBar = i === intervalCount - 1; + const totalCount = coldCount + okCount + hotCount + criticalCount; + const barColdCount = isLatestBar ? coldCount : 0; + const barOkCount = isLatestBar ? okCount : totalCount; + const barHotCount = isLatestBar ? hotCount : 0; + const barCriticalCount = isLatestBar ? criticalCount : 0; + + counts.push( + createMockTemperatureStatusCount(timestampSeconds, barColdCount, barOkCount, barHotCount, barCriticalCount), + ); + } + + return counts; + }, [duration, coldCount, okCount, hotCount, criticalCount]); + + return ( + + ); +} + +const meta = { + title: "Proto Fleet/Dashboard/TemperaturePanel", + component: MockTemperaturePanel, + tags: ["autodocs"], + parameters: { + layout: "padded", + docs: { + description: { + component: + "Temperature monitoring panel that displays the distribution of miners across different temperature ranges (Cold, Normal, Hot, Critical) using the SegmentedMetricPanel.", + }, + }, + }, + decorators: [ + (Story) => ( +
+
+ +
+
+ ), + ], + argTypes: { + duration: { + control: "select", + options: fleetDurations, + description: "Time range for the temperature data", + }, + coldCount: { + control: { type: "number", min: 0, max: 100 }, + description: "Number of miners running cold", + }, + okCount: { + control: { type: "number", min: 0, max: 100 }, + description: "Number of miners at healthy temperature", + }, + hotCount: { + control: { type: "number", min: 0, max: 100 }, + description: "Number of miners running hot", + }, + criticalCount: { + control: { type: "number", min: 0, max: 100 }, + description: "Number of miners at critical temperature", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * Default 24-hour view with typical temperature distribution. + * Shows mostly healthy temps with a few hot miners. + */ +export const Default: Story = { + args: { + duration: "24h", + coldCount: 0, + okCount: 8, + hotCount: 2, + criticalCount: 0, + }, +}; + +/** + * Loading state showing skeleton loaders. + */ +export const Loading: Story = { + args: { + duration: "24h", + coldCount: 0, + okCount: 0, + hotCount: 0, + criticalCount: 0, + isLoading: true, + }, +}; + +/** + * All miners at healthy temperature - ideal state. + */ +export const AllHealthy: Story = { + args: { + duration: "24h", + coldCount: 0, + okCount: 10, + hotCount: 0, + criticalCount: 0, + }, +}; + +/** + * Some miners running hot - warning state. + */ +export const HighTemperatureWarning: Story = { + args: { + duration: "24h", + coldCount: 0, + okCount: 7, + hotCount: 3, + criticalCount: 0, + }, +}; + +/** + * Critical temperature alert - some miners overheating. + */ +export const CriticalTemperature: Story = { + args: { + duration: "24h", + coldCount: 0, + okCount: 6, + hotCount: 2, + criticalCount: 2, + }, +}; + +/** + * Mixed temperature distribution across all ranges. + */ +export const MixedDistribution: Story = { + args: { + duration: "24h", + coldCount: 1, + okCount: 6, + hotCount: 2, + criticalCount: 1, + }, +}; diff --git a/client/src/protoFleet/features/dashboard/components/TemperaturePanel/TemperaturePanel.tsx b/client/src/protoFleet/features/dashboard/components/TemperaturePanel/TemperaturePanel.tsx new file mode 100644 index 000000000..2756bfd86 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/TemperaturePanel/TemperaturePanel.tsx @@ -0,0 +1,81 @@ +import { generateTemperatureHeadline } from "./utils"; +import { type TemperatureStatusCount } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import ChartWidget from "@/protoFleet/features/dashboard/components/ChartWidget"; +import { SegmentedMetricPanel } from "@/protoFleet/features/dashboard/components/SegmentedMetricPanel"; +import type { SegmentConfig } from "@/protoFleet/features/dashboard/components/SegmentedMetricPanel/types"; +import { Triangle } from "@/shared/assets/icons"; +import { FleetDuration } from "@/shared/components/DurationSelector"; +import SkeletonBar from "@/shared/components/SkeletonBar"; + +// Temperature segment configuration +const temperatureSegmentConfig: SegmentConfig = { + cold: { + color: "var(--color-intent-info-fill)", + label: "Cold", + displayInBreakdown: true, + showButton: false, + index: 2, + }, + ok: { + color: "var(--color-intent-info-20)", + label: "Healthy", + displayInBreakdown: true, + index: 3, + showButton: false, + }, + hot: { + color: "var(--color-intent-warning-fill)", + label: "Hot", + displayInBreakdown: true, + showButton: false, + index: 1, + }, + critical: { + color: "var(--color-intent-critical-fill)", + label: "Critical", + displayInBreakdown: true, + showButton: false, + icon: , + index: 0, + buttonVariant: "primary", // Use primary button for critical items + }, +}; + +interface TemperaturePanelProps { + duration: FleetDuration; + /** Temperature status counts — undefined = not loaded yet */ + temperatureStatusCounts: TemperatureStatusCount[] | undefined; +} + +export function TemperaturePanel({ duration, temperatureStatusCounts }: TemperaturePanelProps) { + if (temperatureStatusCounts === undefined) { + const stat = { + label: "Temperature", + value: undefined, + units: "", + }; + + return ( +
+ + + +
+ + + +
+
+ ); + } + + return ( + + ); +} diff --git a/client/src/protoFleet/features/dashboard/components/TemperaturePanel/index.ts b/client/src/protoFleet/features/dashboard/components/TemperaturePanel/index.ts new file mode 100644 index 000000000..bb9c4e0eb --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/TemperaturePanel/index.ts @@ -0,0 +1 @@ +export { TemperaturePanel } from "./TemperaturePanel"; diff --git a/client/src/protoFleet/features/dashboard/components/TemperaturePanel/utils.ts b/client/src/protoFleet/features/dashboard/components/TemperaturePanel/utils.ts new file mode 100644 index 000000000..37b60e03c --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/TemperaturePanel/utils.ts @@ -0,0 +1,30 @@ +import type { SegmentedBarChartData } from "@/protoFleet/features/dashboard/components/SegmentedMetricPanel/types"; + +/** + * Generate temperature-specific headline based on processed data + * @param processedData - Array of arrays of processed chart data (multi-day format) + * @returns Formatted headline string + */ +export const generateTemperatureHeadline = (processedData: SegmentedBarChartData[][]): string => { + // Flatten all data points across all charts + const allDataPoints = processedData.flat(); + + if (allDataPoints.length === 0) { + return "No data"; + } + + // Get the most recent data point + const latestPoint = allDataPoints[allDataPoints.length - 1]; + + // Calculate miners outside safe range (everything except 'ok') + const outsideSafeRange = (latestPoint.cold || 0) + (latestPoint.hot || 0) + (latestPoint.critical || 0); + + if (outsideSafeRange > 0) { + // There are miners outside safe range + const minerText = outsideSafeRange === 1 ? "miner" : "miners"; + return `${outsideSafeRange} ${minerText} outside of safe range`; + } + + // All miners are healthy + return "All miners within optimal range"; +}; diff --git a/client/src/protoFleet/features/dashboard/components/UptimePanel/UptimePanel.stories.tsx b/client/src/protoFleet/features/dashboard/components/UptimePanel/UptimePanel.stories.tsx new file mode 100644 index 000000000..8cd68257a --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/UptimePanel/UptimePanel.stories.tsx @@ -0,0 +1,255 @@ +import { useMemo } from "react"; +import { MemoryRouter } from "react-router-dom"; +import { create } from "@bufbuild/protobuf"; +import type { Meta, StoryObj } from "@storybook/react"; +import { UptimePanel } from "./UptimePanel"; +import { type UptimeStatusCount, UptimeStatusCountSchema } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { durationToHours } from "@/protoFleet/features/dashboard/components/SegmentedMetricPanel/utils"; +import { type FleetDuration, fleetDurations } from "@/shared/components/DurationSelector"; + +// Helper to create mock uptime status counts +const createMockUptimeStatusCount = ( + timestampSeconds: number, + hashingCount: number, + notHashingCount: number, +): UptimeStatusCount => { + return create(UptimeStatusCountSchema, { + timestamp: { + seconds: BigInt(timestampSeconds), + nanos: 0, + }, + hashingCount, + notHashingCount, + }); +}; + +// Mock UptimePanel component for Storybook +interface MockUptimePanelProps { + duration: FleetDuration; + hashingCount: number; + notHashingCount: number; + isLoading?: boolean; // Used to set uptimeStatusCounts to undefined +} + +function MockUptimePanel({ duration, hashingCount, notHashingCount, isLoading = false }: MockUptimePanelProps) { + // Generate multiple data points across the time range to show a proper chart + const uptimeStatusCounts = useMemo(() => { + const durationHours = durationToHours(duration); + const intervalCount = 12; // Match the number of bars in the chart + const intervalHours = durationHours / intervalCount; + + const counts: UptimeStatusCount[] = []; + // eslint-disable-next-line react-hooks/purity + const now = Math.floor(Date.now() / 1000); + const totalMiners = hashingCount + notHashingCount; + + // Create data points for each interval + // Historical bars show 100% hashing, most recent bar shows actual state + for (let i = 0; i < intervalCount; i++) { + const hoursAgo = durationHours - i * intervalHours; + const timestampSeconds = now - Math.floor(hoursAgo * 3600); + + // For the most recent bar (i === intervalCount - 1), use the exact props + // For historical bars, show all miners hashing + const isLatestBar = i === intervalCount - 1; + const barHashingCount = isLatestBar ? hashingCount : totalMiners; + const barNotHashingCount = isLatestBar ? notHashingCount : 0; + + counts.push(createMockUptimeStatusCount(timestampSeconds, barHashingCount, barNotHashingCount)); + } + + return counts; + }, [duration, hashingCount, notHashingCount]); + + return ; +} + +const meta = { + title: "Proto Fleet/Dashboard/UptimePanel", + component: MockUptimePanel, + tags: ["autodocs"], + parameters: { + withRouter: false, + layout: "padded", + docs: { + description: { + component: + "Uptime monitoring panel that displays the distribution of miners between hashing and not hashing states using the SegmentedMetricPanel. Shows real-time streaming updates of miner uptime status.", + }, + }, + }, + decorators: [ + (Story) => ( + +
+
+ +
+
+
+ ), + ], + argTypes: { + duration: { + control: "select", + options: fleetDurations, + description: "Time range for the uptime data", + }, + hashingCount: { + control: { type: "number", min: 0, max: 100 }, + description: "Number of miners currently hashing", + }, + notHashingCount: { + control: { type: "number", min: 0, max: 100 }, + description: "Number of miners not currently hashing", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * Default 24-hour view with typical uptime data. + * Shows 8 miners hashing and 2 not hashing (20% downtime). + */ +export const Default: Story = { + args: { + duration: "24h", + hashingCount: 8, + notHashingCount: 2, + }, +}; + +/** + * Loading state showing skeleton loaders while data is being fetched. + */ +export const Loading: Story = { + args: { + duration: "24h", + hashingCount: 0, + notHashingCount: 0, + isLoading: true, + }, +}; + +/** + * No data state - shown when there is no telemetry data available. + * Displays "No data" message. + */ +export const NoData: Story = { + args: { + duration: "24h", + hashingCount: 0, + notHashingCount: 0, + }, + render: (args) => { + return ; + }, +}; + +/** + * No miners state - shown when there are 0 total miners. + * Displays "No miners" message. + */ +export const NoMiners: Story = { + args: { + duration: "24h", + hashingCount: 0, + notHashingCount: 0, + }, +}; + +/** + * All miners not hashing - critical state with 100% downtime. + * Shows "100% not hashing" headline with action button. + */ +export const AllNotHashing: Story = { + args: { + duration: "24h", + hashingCount: 0, + notHashingCount: 10, + }, +}; + +/** + * All miners are hashing - ideal state with 100% uptime. + * Shows "All miners hashing" headline with no action button. + */ +export const AllHashing: Story = { + args: { + duration: "24h", + hashingCount: 10, + notHashingCount: 0, + }, +}; + +/** + * One miner not hashing - shows singular "1 miner" text. + * Displays action button with count and percentage (10%). + */ +export const OneMinerDown: Story = { + args: { + duration: "24h", + hashingCount: 9, + notHashingCount: 1, + }, +}; + +/** + * Multiple miners not hashing - shows plural "miners" text. + * Displays "2 miners not hashing (20%)" with action button. + */ +export const MultipleMinersDown: Story = { + args: { + duration: "24h", + hashingCount: 8, + notHashingCount: 2, + }, +}; + +/** + * Significant downtime - half the fleet not hashing. + * Shows "5 miners not hashing (50%)" in critical state. + */ +export const SignificantDowntime: Story = { + args: { + duration: "24h", + hashingCount: 5, + notHashingCount: 5, + }, +}; + +/** + * Large fleet with some miners down. + * Demonstrates scaling with 50 miners total. + */ +export const LargeFleet: Story = { + args: { + duration: "24h", + hashingCount: 45, + notHashingCount: 5, + }, +}; + +/** + * 7-day view showing uptime trends over a full week. + */ +export const OneWeek: Story = { + args: { + duration: "7d", + hashingCount: 8, + notHashingCount: 2, + }, +}; + +/** + * 30-day view showing uptime patterns over a month. + */ +export const ThirtyDays: Story = { + args: { + duration: "30d", + hashingCount: 8, + notHashingCount: 2, + }, +}; diff --git a/client/src/protoFleet/features/dashboard/components/UptimePanel/UptimePanel.test.tsx b/client/src/protoFleet/features/dashboard/components/UptimePanel/UptimePanel.test.tsx new file mode 100644 index 000000000..ffbbc2d22 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/UptimePanel/UptimePanel.test.tsx @@ -0,0 +1,167 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import userEvent from "@testing-library/user-event"; +import { UptimePanel } from "./UptimePanel"; +import { type UptimeStatusCount, UptimeStatusCountSchema } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; + +// Mock react-router-dom +const mockNavigate = vi.fn(); +vi.mock("react-router-dom", () => ({ + useNavigate: () => mockNavigate, +})); + +// Helper function to create proper UptimeStatusCount objects +const createMockUptimeStatusCount = ( + timestampSeconds: number, + hashingCount: number, + notHashingCount: number, +): UptimeStatusCount => { + return create(UptimeStatusCountSchema, { + timestamp: { + seconds: BigInt(timestampSeconds), + nanos: 0, + }, + hashingCount, + notHashingCount, + }); +}; + +describe("UptimePanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockNavigate.mockClear(); + }); + + it("renders loading state", () => { + // undefined = not loaded yet (loading state) + render(); + + // Check for skeleton loading state + expect(screen.getByText("Uptime")).toBeInTheDocument(); + }); + + it("renders with all miners hashing", () => { + // Use timestamp from 1 hour ago to ensure it's before chart intervals + const uptimeStatusCounts: UptimeStatusCount[] = [ + createMockUptimeStatusCount(Math.floor(Date.now() / 1000) - 3600, 5, 0), + ]; + + render(); + + expect(screen.getByText("Uptime")).toBeInTheDocument(); + expect(screen.getByText("All miners hashing")).toBeInTheDocument(); + expect(screen.getByText("Not hashing")).toBeInTheDocument(); + expect(screen.getByText("Hashing")).toBeInTheDocument(); + expect(screen.getByText("0% of fleet")).toBeInTheDocument(); + expect(screen.getByText("100% of fleet")).toBeInTheDocument(); + // Button should not be shown when all miners are hashing + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("renders with some miners not hashing", () => { + const uptimeStatusCounts: UptimeStatusCount[] = [ + createMockUptimeStatusCount(Math.floor(Date.now() / 1000) - 3600, 4, 1), + ]; + + render(); + + expect(screen.getByText("Uptime")).toBeInTheDocument(); + expect(screen.getByText("20% not hashing")).toBeInTheDocument(); + expect(screen.getByText("Not hashing")).toBeInTheDocument(); + expect(screen.getByText("Hashing")).toBeInTheDocument(); + expect(screen.getByText("20% of fleet")).toBeInTheDocument(); + expect(screen.getByText("80% of fleet")).toBeInTheDocument(); + // Button should show with singular "miner" + expect(screen.getByRole("button")).toBeInTheDocument(); + expect(screen.getByText("1 miner")).toBeInTheDocument(); + }); + + it("renders with multiple miners not hashing", () => { + const uptimeStatusCounts: UptimeStatusCount[] = [ + createMockUptimeStatusCount(Math.floor(Date.now() / 1000) - 3600, 3, 2), + ]; + + render(); + + expect(screen.getByText("Uptime")).toBeInTheDocument(); + expect(screen.getByText("40% not hashing")).toBeInTheDocument(); + expect(screen.getByText("Not hashing")).toBeInTheDocument(); + expect(screen.getByText("Hashing")).toBeInTheDocument(); + expect(screen.getByText("40% of fleet")).toBeInTheDocument(); + expect(screen.getByText("60% of fleet")).toBeInTheDocument(); + // Button should show with plural "miners" + expect(screen.getByRole("button")).toBeInTheDocument(); + expect(screen.getByText("2 miners")).toBeInTheDocument(); + }); + + it("shows button only when not hashing count > 0", () => { + const uptimeStatusCountsAllHashing: UptimeStatusCount[] = [ + createMockUptimeStatusCount(Math.floor(Date.now() / 1000) - 3600, 5, 0), + ]; + + const { rerender } = render(); + + // Should not show button when count is 0 + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + expect(screen.getByText("All miners hashing")).toBeInTheDocument(); + + // Update with not hashing miners + const uptimeStatusCountsWithNotHashing: UptimeStatusCount[] = [ + createMockUptimeStatusCount(Math.floor(Date.now() / 1000) - 3600, 4, 1), + ]; + + rerender(); + + // Should show button with count when not hashing > 0 + expect(screen.getByRole("button")).toBeInTheDocument(); + expect(screen.getByText("1 miner")).toBeInTheDocument(); + expect(screen.getByText("20% not hashing")).toBeInTheDocument(); + }); + + it("handles empty data", () => { + render(); + + expect(screen.getByText("Uptime")).toBeInTheDocument(); + expect(screen.getByText("No data")).toBeInTheDocument(); + }); + + it("handles different duration props", () => { + // Use timestamp from 3 days ago to work with all durations including 5d + const uptimeStatusCounts: UptimeStatusCount[] = [ + createMockUptimeStatusCount(Math.floor(Date.now() / 1000) - 3 * 24 * 3600, 5, 0), + ]; + + const { rerender } = render(); + expect(screen.getByText("Uptime")).toBeInTheDocument(); + expect(screen.getByText("All miners hashing")).toBeInTheDocument(); + + rerender(); + expect(screen.getByText("Uptime")).toBeInTheDocument(); + expect(screen.getByText("All miners hashing")).toBeInTheDocument(); + + rerender(); + expect(screen.getByText("Uptime")).toBeInTheDocument(); + expect(screen.getByText("All miners hashing")).toBeInTheDocument(); + + rerender(); + expect(screen.getByText("Uptime")).toBeInTheDocument(); + expect(screen.getByText("All miners hashing")).toBeInTheDocument(); + }); + + it("navigates to miners page with filters when clicking not hashing button", async () => { + const user = userEvent.setup(); + const uptimeStatusCounts: UptimeStatusCount[] = [ + createMockUptimeStatusCount(Math.floor(Date.now() / 1000) - 3600, 4, 1), + ]; + + render(); + + // Find and click the "1 miner" button + const button = screen.getByRole("button", { name: /1 miner/i }); + await user.click(button); + + // Verify navigate was called with the correct URL + expect(mockNavigate).toHaveBeenCalledWith("/miners?status=offline,sleeping,needs-attention"); + }); +}); diff --git a/client/src/protoFleet/features/dashboard/components/UptimePanel/UptimePanel.tsx b/client/src/protoFleet/features/dashboard/components/UptimePanel/UptimePanel.tsx new file mode 100644 index 000000000..4ab90ea68 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/UptimePanel/UptimePanel.tsx @@ -0,0 +1,75 @@ +import { useMemo } from "react"; +import { useNavigate } from "react-router-dom"; +import { generateUptimeHeadline } from "./utils"; +import { type UptimeStatusCount } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import ChartWidget from "@/protoFleet/features/dashboard/components/ChartWidget"; +import { SegmentedMetricPanel } from "@/protoFleet/features/dashboard/components/SegmentedMetricPanel"; +import type { SegmentConfig } from "@/protoFleet/features/dashboard/components/SegmentedMetricPanel/types"; +import { FleetDuration } from "@/shared/components/DurationSelector"; +import SkeletonBar from "@/shared/components/SkeletonBar"; + +interface UptimePanelProps { + duration: FleetDuration; + /** Uptime status counts — undefined = not loaded yet */ + uptimeStatusCounts: UptimeStatusCount[] | undefined; +} + +export function UptimePanel({ duration, uptimeStatusCounts }: UptimePanelProps) { + const navigate = useNavigate(); + + // Uptime segment configuration with navigation handler + const uptimeSegmentConfig: SegmentConfig = useMemo( + () => ({ + hashing: { + color: "var(--color-text-primary)", + label: "Hashing", + displayInBreakdown: true, + showButton: false, + index: 1, + }, + notHashing: { + color: "var(--color-core-primary-10)", + label: "Not hashing", + displayInBreakdown: true, + showButton: true, + buttonVariant: "secondary", + index: 0, + onClick: () => { + // Navigate to miners page with offline, sleeping, and needs-attention status filters + navigate("/miners?status=offline,sleeping,needs-attention"); + }, + }, + }), + [navigate], + ); + + if (uptimeStatusCounts === undefined) { + const stat = { + label: "Uptime", + value: undefined, + units: "", + }; + + return ( +
+ + + +
+ + +
+
+ ); + } + + return ( + + ); +} diff --git a/client/src/protoFleet/features/dashboard/components/UptimePanel/index.ts b/client/src/protoFleet/features/dashboard/components/UptimePanel/index.ts new file mode 100644 index 000000000..e031b88ba --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/UptimePanel/index.ts @@ -0,0 +1 @@ +export { UptimePanel } from "./UptimePanel"; diff --git a/client/src/protoFleet/features/dashboard/components/UptimePanel/utils.test.ts b/client/src/protoFleet/features/dashboard/components/UptimePanel/utils.test.ts new file mode 100644 index 000000000..7993be8dc --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/UptimePanel/utils.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from "vitest"; +import { generateUptimeHeadline } from "./utils"; +import type { SegmentedBarChartData } from "@/protoFleet/features/dashboard/components/SegmentedMetricPanel/types"; + +describe("generateUptimeHeadline", () => { + it("returns 'No data' when no data points are provided", () => { + const result = generateUptimeHeadline([]); + expect(result).toBe("No data"); + }); + + it("returns 'No data' when empty array of arrays is provided", () => { + const result = generateUptimeHeadline([[]]); + expect(result).toBe("No data"); + }); + + it("returns 'All miners hashing' when all miners are hashing", () => { + const data: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 5, + notHashing: 0, + }, + ], + ]; + + const result = generateUptimeHeadline(data); + expect(result).toBe("All miners hashing"); + }); + + it("returns percentage when only one miner is not hashing", () => { + const data: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 4, + notHashing: 1, + }, + ], + ]; + + const result = generateUptimeHeadline(data); + expect(result).toBe("20% not hashing"); + }); + + it("returns percentage when multiple miners are not hashing", () => { + const data: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 3, + notHashing: 2, + }, + ], + ]; + + const result = generateUptimeHeadline(data); + expect(result).toBe("40% not hashing"); + }); + + it("calculates correct percentage for not hashing miners", () => { + const data: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 97, + notHashing: 3, + }, + ], + ]; + + const result = generateUptimeHeadline(data); + expect(result).toBe("3% not hashing"); + }); + + it("rounds percentage to nearest integer", () => { + const data: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 6, + notHashing: 1, + }, + ], + ]; + + const result = generateUptimeHeadline(data); + // 1/7 = 14.28%, should round to 14% + expect(result).toBe("14% not hashing"); + }); + + it("uses the most recent data point from multiple data points", () => { + const data: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now() - 10000, + hashing: 5, + notHashing: 0, + }, + { + datetime: Date.now() - 5000, + hashing: 4, + notHashing: 1, + }, + { + datetime: Date.now(), + hashing: 3, + notHashing: 2, + }, + ], + ]; + + const result = generateUptimeHeadline(data); + expect(result).toBe("40% not hashing"); + }); + + it("flattens multi-day data and uses the last point", () => { + const data: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now() - 20000, + hashing: 5, + notHashing: 0, + }, + ], + [ + { + datetime: Date.now() - 10000, + hashing: 4, + notHashing: 1, + }, + ], + [ + { + datetime: Date.now(), + hashing: 2, + notHashing: 3, + }, + ], + ]; + + const result = generateUptimeHeadline(data); + expect(result).toBe("60% not hashing"); + }); + + it("returns 'No miners' when total count is 0", () => { + const data: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 0, + notHashing: 0, + }, + ], + ]; + + const result = generateUptimeHeadline(data); + expect(result).toBe("No miners"); + }); + + it("handles undefined notHashing field", () => { + const data: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 5, + // notHashing is undefined + }, + ], + ]; + + const result = generateUptimeHeadline(data); + expect(result).toBe("All miners hashing"); + }); + + it("handles undefined hashing field", () => { + const data: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + // hashing is undefined + notHashing: 2, + }, + ], + ]; + + const result = generateUptimeHeadline(data); + expect(result).toBe("100% not hashing"); + }); + + it("handles 100% not hashing", () => { + const data: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 0, + notHashing: 5, + }, + ], + ]; + + const result = generateUptimeHeadline(data); + expect(result).toBe("100% not hashing"); + }); + + it("handles large numbers of miners", () => { + const data: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 950, + notHashing: 50, + }, + ], + ]; + + const result = generateUptimeHeadline(data); + expect(result).toBe("5% not hashing"); + }); +}); diff --git a/client/src/protoFleet/features/dashboard/components/UptimePanel/utils.ts b/client/src/protoFleet/features/dashboard/components/UptimePanel/utils.ts new file mode 100644 index 000000000..56f8aad14 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/UptimePanel/utils.ts @@ -0,0 +1,35 @@ +import type { SegmentedBarChartData } from "@/protoFleet/features/dashboard/components/SegmentedMetricPanel/types"; + +/** + * Generate uptime-specific headline based on processed data + * @param processedData - Array of arrays of processed chart data (multi-day format) + * @returns Formatted headline string + */ +export const generateUptimeHeadline = (processedData: SegmentedBarChartData[][]): string => { + // Flatten all data points across all charts + const allDataPoints = processedData.flat(); + + if (allDataPoints.length === 0) { + return "No data"; + } + + // Get the most recent data point + const latestPoint = allDataPoints[allDataPoints.length - 1]; + + const notHashingCount = latestPoint.notHashing || 0; + const totalMiners = (latestPoint.hashing || 0) + notHashingCount; + + if (totalMiners === 0) { + return "No miners"; + } + + if (notHashingCount === 0) { + // All miners are hashing + return "All miners hashing"; + } + + // Calculate percentage of miners not hashing + const notHashingPercentage = Math.round((notHashingCount / totalMiners) * 100); + + return `${notHashingPercentage}% not hashing`; +}; diff --git a/client/src/protoFleet/features/dashboard/constants.ts b/client/src/protoFleet/features/dashboard/constants.ts new file mode 100644 index 000000000..2044b436c --- /dev/null +++ b/client/src/protoFleet/features/dashboard/constants.ts @@ -0,0 +1,2 @@ +export const dangerInactivePercentage = 5; +export const dangerOfflinePercentage = 5; diff --git a/client/src/protoFleet/features/dashboard/index.ts b/client/src/protoFleet/features/dashboard/index.ts new file mode 100644 index 000000000..2f27e2174 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/index.ts @@ -0,0 +1,10 @@ +// Dashboard pages +export { default as Dashboard } from "./pages/Dashboard"; + +// Dashboard components +export { default as ChartWidget } from "./components/ChartWidget"; +export { default as FleetHealth } from "./components/FleetHealth"; +export { default as SectionHeading } from "./components/SectionHeading"; + +// Types +export type { AggregateStats, StatsArgs, TimeSeriesDataPoint, Value } from "./types"; diff --git a/client/src/protoFleet/features/dashboard/pages/Dashboard.tsx b/client/src/protoFleet/features/dashboard/pages/Dashboard.tsx new file mode 100644 index 000000000..5509c00ba --- /dev/null +++ b/client/src/protoFleet/features/dashboard/pages/Dashboard.tsx @@ -0,0 +1,166 @@ +import { useMemo } from "react"; +import { MeasurementType, type Metric } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { useComponentErrors } from "@/protoFleet/api/useComponentErrors"; +import useFleetCounts from "@/protoFleet/api/useFleetCounts"; +import { useOnboardedStatus } from "@/protoFleet/api/useOnboardedStatus"; +import { useTelemetryMetrics } from "@/protoFleet/api/useTelemetryMetrics"; +import { POLL_INTERVAL_MS } from "@/protoFleet/constants/polling"; +import { EfficiencyPanel } from "@/protoFleet/features/dashboard/components/EfficiencyPanel"; +import FleetHealth from "@/protoFleet/features/dashboard/components/FleetHealth"; +import { HashratePanel } from "@/protoFleet/features/dashboard/components/HashratePanel"; +import { PowerPanel } from "@/protoFleet/features/dashboard/components/PowerPanel"; +import SectionHeading from "@/protoFleet/features/dashboard/components/SectionHeading"; +import { TemperaturePanel } from "@/protoFleet/features/dashboard/components/TemperaturePanel"; +import { UptimePanel } from "@/protoFleet/features/dashboard/components/UptimePanel"; +import FleetErrors from "@/protoFleet/features/kpis/components/FleetErrors"; +import { MinersPage } from "@/protoFleet/features/onboarding"; +import { CompleteSetup } from "@/protoFleet/features/onboarding/components/CompleteSetup"; +import { useDuration, useSetDuration } from "@/protoFleet/store"; +import DurationSelector, { fleetDurations } from "@/shared/components/DurationSelector"; +import ProgressCircular from "@/shared/components/ProgressCircular"; +import { useStickyState } from "@/shared/hooks/useStickyState"; +import { buildVersionInfo } from "@/shared/utils/version"; + +// Constants for telemetry options - stable references to prevent unnecessary re-renders +const ALL_DEVICES: string[] = []; +const ALL_MEASUREMENT_TYPES: MeasurementType[] = [ + MeasurementType.HASHRATE, + MeasurementType.POWER, + MeasurementType.TEMPERATURE, + MeasurementType.EFFICIENCY, + MeasurementType.UPTIME, +]; + +const Dashboard = () => { + const { devicePaired, statusLoaded } = useOnboardedStatus(); + const duration = useDuration(); + const setDuration = useSetDuration(); + const currentYear = new Date().getFullYear(); + const { refs } = useStickyState(); + + // Fleet counts — polled for fresh minerStateCounts + const { totalMiners, stateCounts, hasLoaded: countsLoaded } = useFleetCounts({ pollIntervalMs: POLL_INTERVAL_MS }); + + // Component errors — polled, local state (no store) + const { controlBoardErrors, fanErrors, hashboardErrors, psuErrors } = useComponentErrors({ + pollIntervalMs: POLL_INTERVAL_MS, + }); + + // Combined telemetry — polled, replaces data each cycle (no streaming merge) + const telemetryOptions = useMemo( + () => ({ + deviceIds: ALL_DEVICES, + measurementTypes: ALL_MEASUREMENT_TYPES, + duration, + enabled: true, + pollIntervalMs: POLL_INTERVAL_MS, + }), + [duration], + ); + + const { data: telemetryData } = useTelemetryMetrics(telemetryOptions); + + // Extract metrics for panels — filter by measurement type + const allMetrics = telemetryData?.metrics; + const hashrateMetrics = useMemo( + () => allMetrics?.filter((m: Metric) => m.measurementType === MeasurementType.HASHRATE), + [allMetrics], + ); + const powerMetrics = useMemo( + () => allMetrics?.filter((m: Metric) => m.measurementType === MeasurementType.POWER), + [allMetrics], + ); + const efficiencyMetrics = useMemo( + () => allMetrics?.filter((m: Metric) => m.measurementType === MeasurementType.EFFICIENCY), + [allMetrics], + ); + const temperatureStatusCounts = telemetryData?.temperatureStatusCounts; + const uptimeStatusCounts = telemetryData?.uptimeStatusCounts; + + if (!statusLoaded) { + return ( +
+ +
+ ); + } + + return ( +
+ {devicePaired ? ( +
+ + + {/* Overview Section */} +
+ +
+ + +
+
+ + {/* Performance Section */} +
+
+
+ + + +
+ +
+ + + + +
+ + +
+
+ +

+ Some devices do not make all data available to Proto Fleet. +

+ {/* eslint-disable-next-line react-hooks/refs */} +
+
+ + {/* Privacy Policy */} +
+

+ Powerful mining tools. Built for decentralization.{" "} + + Proto Fleet {buildVersionInfo.version} © {currentYear} Block, Inc.{" "} + + Privacy Notice + + +

+
+
+ ) : ( + + )} +
+ ); +}; + +export default Dashboard; diff --git a/client/src/protoFleet/features/dashboard/types.ts b/client/src/protoFleet/features/dashboard/types.ts new file mode 100644 index 000000000..1e356dee0 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/types.ts @@ -0,0 +1,39 @@ +import { FleetDuration } from "@/shared/components/DurationSelector"; + +export type Value = number | null; + +export type AggregateStats = { + avg?: Value; + max?: Value; + min?: Value; +}; + +export type TimeSeriesDataPoint = { + datetime: number; + value: Value; +}; + +export type StatsArgs = AggregateStats & { lowestPerformer?: string }; + +/** + * ProtoFleet specific outlet context for KPI data + */ +export interface KpiOutletContext { + duration: FleetDuration; + minerHashrate: { + hashrate: TimeSeriesDataPoint[]; + aggregates: AggregateStats; + }; + minerEfficiency: { + efficiency: TimeSeriesDataPoint[]; + aggregates: AggregateStats; + }; + minerPowerUsage: { + powerUsage: TimeSeriesDataPoint[]; + aggregates: AggregateStats; + }; + minerTemperature: { + temperature: TimeSeriesDataPoint[]; + aggregates: AggregateStats; + }; +} diff --git a/client/src/protoFleet/features/dashboard/utils/chartDataPadding.test.ts b/client/src/protoFleet/features/dashboard/utils/chartDataPadding.test.ts new file mode 100644 index 000000000..c913608a5 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/utils/chartDataPadding.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; +import { padChartDataWithNulls } from "./chartDataPadding"; +import type { ChartData } from "@/shared/components/LineChart/types"; + +describe("padChartDataWithNulls", () => { + it("should return empty array when input is empty", () => { + const result = padChartDataWithNulls([], "1h"); + expect(result).toEqual([]); + }); + + it("should pad data with null values for missing timestamps", () => { + const now = Date.now(); + const thirtyMinutesAgo = now - 1800 * 1000; + + // Simulate having only 2 data points in the last hour + const data: ChartData[] = [ + { datetime: thirtyMinutesAgo, hashrate: 100 }, + { datetime: now, hashrate: 150 }, + ]; + + const result = padChartDataWithNulls(data, "1h"); + + // Should have many more points (1 hour / 90 seconds = 40 buckets) + expect(result.length).toBeGreaterThan(2); + expect(result.length).toBeLessThanOrEqual(41); // 3600 / 90 = 40, plus potential rounding + + // First points should be null (no data in the first part of the hour) + const firstNullPoint = result.find((point) => point.datetime < thirtyMinutesAgo); + expect(firstNullPoint).toBeDefined(); + expect(firstNullPoint?.hashrate).toBeNull(); + + // Original data points should be preserved + const preservedPoints = result.filter((point) => point.hashrate !== null); + expect(preservedPoints.length).toBeGreaterThanOrEqual(2); + }); + + it("should preserve all numeric fields from original data", () => { + const now = Date.now(); + + interface MultiMetricData extends ChartData { + hashrate: number | null; + power: number | null; + efficiency: number | null; + } + + const data: MultiMetricData[] = [{ datetime: now, hashrate: 100, power: 2000, efficiency: 50 }]; + + const result = padChartDataWithNulls(data, "1h"); + + // Check that a null point has all the same fields + const nullPoint = result.find((point) => point.hashrate === null); + expect(nullPoint).toBeDefined(); + expect(nullPoint).toHaveProperty("hashrate"); + expect(nullPoint).toHaveProperty("power"); + expect(nullPoint).toHaveProperty("efficiency"); + expect(nullPoint?.hashrate).toBeNull(); + expect(nullPoint?.power).toBeNull(); + expect(nullPoint?.efficiency).toBeNull(); + }); + + it("should handle different duration strings correctly with dynamic granularity", () => { + const now = Date.now(); + + const data: ChartData[] = [{ datetime: now, hashrate: 100 }]; + + // 1 hour should have ~40 buckets (3600s / 90s granularity) + const result1h = padChartDataWithNulls(data, "1h"); + expect(result1h.length).toBeGreaterThan(30); + expect(result1h.length).toBeLessThanOrEqual(50); + + // 7 days use 900s granularity: 672 buckets (604800s / 900s) + const result7d = padChartDataWithNulls(data, "7d"); + expect(result7d.length).toBeGreaterThan(650); + expect(result7d.length).toBeLessThanOrEqual(1000); + + // 24 hours uses 90s granularity: ~960 buckets + const result24h = padChartDataWithNulls(data, "24h"); + expect(result24h.length).toBeGreaterThan(900); + expect(result24h.length).toBeLessThanOrEqual(1000); + }); + + it("should use 90-second granularity for short durations (1h)", () => { + const now = Date.now(); + const granularity = 90 * 1000; // 90 seconds in milliseconds for 1h duration + + const data: ChartData[] = [{ datetime: now, hashrate: 100 }]; + + const result = padChartDataWithNulls(data, "1h"); + + // Check that timestamps are at 90-second intervals for 1h duration + for (let i = 1; i < result.length; i++) { + const timeDiff = result[i].datetime - result[i - 1].datetime; + expect(timeDiff).toBe(granularity); + } + }); + + it("should match existing data to correct buckets", () => { + const now = Date.now(); + const granularity = 90 * 1000; + + // Create a timestamp that's already on a 90-second boundary + const bucketTime = Math.floor(now / granularity) * granularity; + + const data: ChartData[] = [{ datetime: bucketTime, hashrate: 100 }]; + + const result = padChartDataWithNulls(data, "1h"); + + // The data point should be preserved (not replaced with null) + const matchingPoint = result.find((point) => { + const pointBucket = Math.floor(point.datetime / granularity) * granularity; + return pointBucket === bucketTime; + }); + + expect(matchingPoint).toBeDefined(); + expect(matchingPoint?.hashrate).toBe(100); + }); + + it("should not pad beyond the last actual data point timestamp", () => { + const now = Date.now(); + const fiveMinutesAgo = now - 5 * 60 * 1000; + const tenMinutesAgo = now - 10 * 60 * 1000; + + // Create data that stops 5 minutes ago (not at current time) + const data: ChartData[] = [ + { datetime: tenMinutesAgo, hashrate: 100 }, + { datetime: fiveMinutesAgo, hashrate: 150 }, + ]; + + const result = padChartDataWithNulls(data, "1h"); + + // Get the last timestamp in the result + const lastTimestamp = result[result.length - 1].datetime; + + // The last timestamp should be close to fiveMinutesAgo (within one bucket) + // and should NOT extend to current time + const granularity = 90 * 1000; + const expectedLastBucket = Math.floor(fiveMinutesAgo / granularity) * granularity; + expect(lastTimestamp).toBe(expectedLastBucket); + + // Verify no timestamps are close to current time + const timeSinceLastPoint = now - lastTimestamp; + expect(timeSinceLastPoint).toBeGreaterThan(4 * 60 * 1000); // At least 4 minutes ago + + // Verify we don't have null datapoints at the end (after the last actual data) + const lastActualDataBucket = Math.floor(fiveMinutesAgo / granularity) * granularity; + const pointsAfterLastData = result.filter((point) => point.datetime > lastActualDataBucket); + expect(pointsAfterLastData.length).toBe(0); + }); +}); diff --git a/client/src/protoFleet/features/dashboard/utils/chartDataPadding.ts b/client/src/protoFleet/features/dashboard/utils/chartDataPadding.ts new file mode 100644 index 000000000..01cd5c3a8 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/utils/chartDataPadding.ts @@ -0,0 +1,81 @@ +import { getGranularityForDuration } from "@/protoFleet/features/dashboard/utils/granularity"; +import { type FleetDuration, getFleetDurationMs } from "@/shared/components/DurationSelector"; +import type { ChartData } from "@/shared/components/LineChart/types"; + +/** + * Pad chart data with null values for missing timestamps in the requested duration + * + * @param data - The actual chart data from the API + * @param duration - The requested time duration (e.g., "24h", "7d") + * @returns Chart data padded with null values for the full time range + * + * @example + * // If user selects 24h but only has 4h of data: + * // Returns 24h worth of datapoints, with first 20h as null values + * const paddedData = padChartDataWithNulls(chartData, "24h"); + */ +export function padChartDataWithNulls(data: T[], duration: FleetDuration): T[] { + if (!data || data.length === 0) { + return data; + } + + const durationMs = getFleetDurationMs(duration); + const granularitySeconds = getGranularityForDuration(duration); + const now = Date.now(); + const startTime = now - durationMs; + + // Find the first bucket boundary at or before startTime + const granularityMs = granularitySeconds * 1000; + const firstBucket = Math.floor(startTime / granularityMs) * granularityMs; + + // Use the last actual data point as the end boundary, not current time + // Filter out invalid datetime values and provide fallback to current time + // Safe: data.length === 0 is handled by early return above, so Math.max never receives empty array + const validTimestamps = data.map((d) => d.datetime).filter((dt) => typeof dt === "number" && !isNaN(dt)); + const lastDataTimestamp = validTimestamps.length > 0 ? Math.max(...validTimestamps) : now; + const lastBucket = Math.floor(lastDataTimestamp / granularityMs) * granularityMs; + + // Generate all expected timestamps at the appropriate granularity interval + const expectedTimestamps: number[] = []; + for (let bucketTime = firstBucket; bucketTime <= lastBucket; bucketTime += granularityMs) { + expectedTimestamps.push(bucketTime); + } + + // Create a map of existing data by timestamp + const existingDataMap = new Map(); + data.forEach((point) => { + // Round to nearest granularity bucket to match expected timestamps + const bucketTime = Math.floor(point.datetime / granularityMs) * granularityMs; + existingDataMap.set(bucketTime, point); + }); + + // Build the padded dataset + const paddedData: T[] = expectedTimestamps.map((timestamp) => { + const existingPoint = existingDataMap.get(timestamp); + + if (existingPoint) { + // Use the bucketed timestamp to ensure consistent spacing + return { ...existingPoint, datetime: timestamp }; + } + + // Create a null datapoint for this timestamp + // TypeScript needs help inferring the shape, so we use type assertion + const nullPoint: ChartData = { + datetime: timestamp, + }; + + // Add null for all numeric keys from the first data point + if (data.length > 0) { + const samplePoint = data[0]; + Object.keys(samplePoint).forEach((key) => { + if (key !== "datetime" && typeof samplePoint[key as keyof T] === "number") { + (nullPoint as any)[key] = null; + } + }); + } + + return nullPoint as T; + }); + + return paddedData; +} diff --git a/client/src/protoFleet/features/dashboard/utils/createMockMetric.ts b/client/src/protoFleet/features/dashboard/utils/createMockMetric.ts new file mode 100644 index 000000000..a7f25acfd --- /dev/null +++ b/client/src/protoFleet/features/dashboard/utils/createMockMetric.ts @@ -0,0 +1,29 @@ +import { create } from "@bufbuild/protobuf"; +import { + AggregatedValueSchema, + AggregationType, + type MeasurementType, + type Metric, + MetricSchema, +} from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; + +export const createMockMetric = ( + measurementType: MeasurementType, + avgValue: number, + timestampSeconds: number, +): Metric => { + return create(MetricSchema, { + measurementType, + openTime: { + seconds: BigInt(timestampSeconds), + nanos: 0, + }, + aggregatedValues: [ + create(AggregatedValueSchema, { + aggregationType: AggregationType.AVERAGE, + value: avgValue, + }), + ], + deviceCount: 1, + }); +}; diff --git a/client/src/protoFleet/features/dashboard/utils/granularity.ts b/client/src/protoFleet/features/dashboard/utils/granularity.ts new file mode 100644 index 000000000..5d1804651 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/utils/granularity.ts @@ -0,0 +1,38 @@ +import { type FleetDuration, getFleetDurationMs } from "@/shared/components/DurationSelector"; + +const DEFAULT_GRANULARITY_SECONDS = 90; +const GRANULARITY_48H_SECONDS = 180; // 3 minutes +const GRANULARITY_5D_SECONDS = 600; // 10 minutes +const GRANULARITY_7D_SECONDS = 900; // 15 minutes (672 buckets for 7d, aligned with hourly aggregates) +const GRANULARITY_30D_SECONDS = 2700; // 45 minutes (~960 buckets for 30d) +const GRANULARITY_90D_SECONDS = 8100; // 2.25 hours (~960 buckets for 90d) +const GRANULARITY_1Y_SECONDS = 32850; // ~9 hours (~960 buckets for 1y) + +const HOURS_48_IN_SECONDS = 48 * 3600; +const DAYS_5_IN_SECONDS = 5 * 24 * 3600; +const DAYS_7_IN_SECONDS = 7 * 24 * 3600; +const DAYS_30_IN_SECONDS = 30 * 24 * 3600; +const DAYS_90_IN_SECONDS = 90 * 24 * 3600; +const DAYS_365_IN_SECONDS = 365 * 24 * 3600; + +/** + * Calculate optimal granularity based on duration to stay within backend LIMIT. + * Backend has LIMIT of 1000 buckets, so we adjust granularity for longer durations. + * + * Note: These thresholds are intentionally different from backend data source selection + * (raw ≤24h, hourly 24h-10d, daily >10d). The backend data source determines WHICH table + * to query for performance, while this granularity controls HOW MANY buckets to return. + * The backend aggregates its chosen data source to match this requested granularity. + */ +export const getGranularityForDuration = (duration: FleetDuration): number => { + const totalSeconds = getFleetDurationMs(duration) / 1000; + + // Granularity thresholds ensure ~960 buckets max for chart rendering performance + if (totalSeconds >= DAYS_365_IN_SECONDS) return GRANULARITY_1Y_SECONDS; // 1y -> ~9 hours + if (totalSeconds >= DAYS_90_IN_SECONDS) return GRANULARITY_90D_SECONDS; // 90d -> 2.25 hours + if (totalSeconds >= DAYS_30_IN_SECONDS) return GRANULARITY_30D_SECONDS; // 30d -> 45 min + if (totalSeconds >= DAYS_7_IN_SECONDS) return GRANULARITY_7D_SECONDS; // 7d -> 15 min + if (totalSeconds >= DAYS_5_IN_SECONDS) return GRANULARITY_5D_SECONDS; // 5d -> 10 min + if (totalSeconds >= HOURS_48_IN_SECONDS) return GRANULARITY_48H_SECONDS; // 48h -> 3 min + return DEFAULT_GRANULARITY_SECONDS; // Default for shorter durations +}; diff --git a/client/src/protoFleet/features/dashboard/utils/metricNormalization.test.ts b/client/src/protoFleet/features/dashboard/utils/metricNormalization.test.ts new file mode 100644 index 000000000..561bd7205 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/utils/metricNormalization.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { normalizeEfficiencyToJTH, normalizeHashrateToTHs, normalizePowerToKW } from "./metricNormalization"; + +describe("metricNormalization", () => { + describe("normalizeEfficiencyToJTH", () => { + it("keeps already-normalized J/TH values unchanged", () => { + expect(normalizeEfficiencyToJTH(24.4)).toBe(24.4); + }); + + it("converts raw J/H values to J/TH", () => { + expect(normalizeEfficiencyToJTH(24.4e-12)).toBeCloseTo(24.4); + }); + + it("converts accidentally over-converted efficiency values back to J/TH", () => { + expect(normalizeEfficiencyToJTH(24.4e12)).toBeCloseTo(24.4); + }); + }); + + describe("normalizePowerToKW", () => { + it("keeps already-normalized kW values unchanged", () => { + expect(normalizePowerToKW(3.6, 1)).toBe(3.6); + }); + + it("converts raw W values to kW", () => { + expect(normalizePowerToKW(3600, 1)).toBe(3.6); + }); + + it("keeps low valid kW values unchanged", () => { + expect(normalizePowerToKW(0.0036, 1)).toBe(0.0036); + }); + + it("skips normalization when deviceCount is invalid", () => { + expect(normalizePowerToKW(3600, 0)).toBe(3600); + expect(normalizePowerToKW(3600, NaN)).toBe(3600); + }); + }); + + describe("normalizeHashrateToTHs", () => { + it("keeps already-normalized TH/s values unchanged", () => { + expect(normalizeHashrateToTHs(120, 1)).toBe(120); + }); + + it("converts raw H/s values to TH/s", () => { + expect(normalizeHashrateToTHs(120e12, 1)).toBe(120); + }); + + it("keeps low valid TH/s values unchanged", () => { + expect(normalizeHashrateToTHs(120e-12, 1)).toBe(120e-12); + }); + + it("skips normalization when deviceCount is invalid", () => { + expect(normalizeHashrateToTHs(120e12, 0)).toBe(120e12); + expect(normalizeHashrateToTHs(120e12, NaN)).toBe(120e12); + }); + }); +}); diff --git a/client/src/protoFleet/features/dashboard/utils/metricNormalization.ts b/client/src/protoFleet/features/dashboard/utils/metricNormalization.ts new file mode 100644 index 000000000..fe170bbf0 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/utils/metricNormalization.ts @@ -0,0 +1,57 @@ +/** + * Normalizes telemetry metric values into dashboard display units. + * + * The API should already return display units, but older/mixed datasets can + * contain raw storage units. These guards keep charts resilient across both. + */ + +const HASHRATE_RAW_PER_DEVICE_THRESHOLD_HS = 1e9; // raw H/s is orders of magnitude larger than TH/s + +const POWER_RAW_PER_DEVICE_THRESHOLD_W = 100; // miners in raw watts are typically well above this + +const EFFICIENCY_RAW_THRESHOLD_JH = 1e-6; // raw J/H values are tiny (e.g. 24e-12) +const EFFICIENCY_OVER_CONVERTED_THRESHOLD_JTH = 1e6; // accidentally converted values become astronomically large + +const hasValidDeviceCount = (deviceCount: number): boolean => { + return Number.isFinite(deviceCount) && deviceCount > 0; +}; + +export const normalizeHashrateToTHs = (value: number, deviceCount: number): number => { + if (!Number.isFinite(value) || !hasValidDeviceCount(deviceCount)) return value; + + const perDevice = Math.abs(value) / deviceCount; + + if (perDevice > HASHRATE_RAW_PER_DEVICE_THRESHOLD_HS) { + return value / 1e12; + } + + return value; +}; + +export const normalizePowerToKW = (value: number, deviceCount: number): number => { + if (!Number.isFinite(value) || !hasValidDeviceCount(deviceCount)) return value; + + const perDevice = Math.abs(value) / deviceCount; + + if (perDevice > POWER_RAW_PER_DEVICE_THRESHOLD_W) { + return value / 1e3; + } + + return value; +}; + +export const normalizeEfficiencyToJTH = (value: number): number => { + if (!Number.isFinite(value)) return value; + + const absValue = Math.abs(value); + + if (absValue > 0 && absValue <= EFFICIENCY_RAW_THRESHOLD_JH) { + return value * 1e12; + } + + if (absValue >= EFFICIENCY_OVER_CONVERTED_THRESHOLD_JTH) { + return value / 1e12; + } + + return value; +}; diff --git a/client/src/protoFleet/features/dashboard/utils/minerCountSubtitle.test.ts b/client/src/protoFleet/features/dashboard/utils/minerCountSubtitle.test.ts new file mode 100644 index 000000000..f234f10b8 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/utils/minerCountSubtitle.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { getMinerCountSubtitle } from "./minerCountSubtitle"; + +describe("getMinerCountSubtitle", () => { + it("returns subtitle when some miners are not reporting", () => { + const result = getMinerCountSubtitle(3, 5); + expect(result).toBe("3 of 5 miners reporting"); + }); + + it("returns subtitle when only one miner is reporting", () => { + const result = getMinerCountSubtitle(1, 10); + expect(result).toBe("1 of 10 miners reporting"); + }); + + it("returns undefined when all miners are reporting", () => { + const result = getMinerCountSubtitle(5, 5); + expect(result).toBeUndefined(); + }); + + it("returns undefined when device count equals total miners", () => { + const result = getMinerCountSubtitle(10, 10); + expect(result).toBeUndefined(); + }); + + it("returns undefined when device count is greater than total miners", () => { + const result = getMinerCountSubtitle(15, 10); + expect(result).toBeUndefined(); + }); + + it("returns undefined when device count is null", () => { + const result = getMinerCountSubtitle(null, 5); + expect(result).toBeUndefined(); + }); + + it("returns undefined when total miners is zero", () => { + const result = getMinerCountSubtitle(0, 0); + expect(result).toBeUndefined(); + }); + + it("returns undefined when total miners is negative", () => { + const result = getMinerCountSubtitle(5, -1); + expect(result).toBeUndefined(); + }); + + it("returns subtitle when zero miners are reporting", () => { + const result = getMinerCountSubtitle(0, 5); + expect(result).toBe("0 of 5 miners reporting"); + }); + + it("handles large numbers correctly", () => { + const result = getMinerCountSubtitle(999, 1000); + expect(result).toBe("999 of 1000 miners reporting"); + }); +}); diff --git a/client/src/protoFleet/features/dashboard/utils/minerCountSubtitle.ts b/client/src/protoFleet/features/dashboard/utils/minerCountSubtitle.ts new file mode 100644 index 000000000..556b55b7d --- /dev/null +++ b/client/src/protoFleet/features/dashboard/utils/minerCountSubtitle.ts @@ -0,0 +1,15 @@ +/** + * Generates a subtitle showing how many miners are reporting data. + * Only returns a subtitle when not all miners are reporting. + * + * @param deviceCount - Number of miners reporting this metric + * @param totalMiners - Total number of miners in the fleet + * @returns Subtitle string or undefined if all miners are reporting + */ +export function getMinerCountSubtitle(deviceCount: number | null, totalMiners: number): string | undefined { + if (deviceCount === null || totalMiners <= 0 || deviceCount >= totalMiners) { + return undefined; + } + + return `${deviceCount} of ${totalMiners} miners reporting`; +} diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/ActionBar.stories.tsx b/client/src/protoFleet/features/fleetManagement/components/ActionBar/ActionBar.stories.tsx new file mode 100644 index 000000000..70e8c7bb1 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/ActionBar.stories.tsx @@ -0,0 +1,44 @@ +import ActionBarComponent from "."; +import MinerActionsMenu from "@/protoFleet/features/fleetManagement/components/MinerActionsMenu"; +import { Toaster as ToasterComponent } from "@/shared/features/toaster"; + +interface ActionBarArgs { + numberOfMiners: number; +} + +export const ActionBar = ({ numberOfMiners }: ActionBarArgs) => { + const selectedMiners = Array(numberOfMiners).fill("MinerId"); + + return ( +
+
+ +
+ ( + setHidden(true)} + onActionComplete={() => setHidden(false)} + /> + )} + /> +
+ ); +}; + +export default { + title: "Proto Fleet/Action Bar", + args: { + numberOfMiners: 1, + }, + argTypes: { + numberOfMiners: { + control: { type: "range", min: 1, max: 25, step: 1 }, + }, + }, +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/ActionBar.test.tsx b/client/src/protoFleet/features/fleetManagement/components/ActionBar/ActionBar.test.tsx new file mode 100644 index 000000000..3cf3ffcc4 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/ActionBar.test.tsx @@ -0,0 +1,151 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import ActionBar from "."; +import MinerActionsMenu from "@/protoFleet/features/fleetManagement/components/MinerActionsMenu"; + +// MinerActionsMenu imports hooks from the removed fleet store slice. +// Mock it so the test that renders it directly doesn't crash. +vi.mock("@/protoFleet/features/fleetManagement/components/MinerActionsMenu", () => ({ + default: ({ onActionStart }: { onActionStart?: () => void }) => ( +
+ More + + +
+ ), +})); + +vi.mock("@/protoFleet/api/usePools", () => ({ + default: () => ({ + pools: [], + validatePool: vi.fn(({ onSuccess }) => { + onSuccess?.(); + }), + validatePoolPending: false, + }), +})); + +describe("Action Bar", () => { + const actionBarTestId = "action-bar"; + + const actionBarProps = { + selectedItems: ["MAC1"], + renderActions: () =>
Action
, + }; + + const minersText = "miners selected"; + + test("renders action bar correctly", () => { + const { getByTestId, queryByText } = render(); + + const closeButton = getByTestId("close-button"); + expect(closeButton).toBeDefined(); + const minersElement = queryByText(minersText); + expect(minersElement).toBeDefined(); + + const actionButton = queryByText("Action"); + expect(actionButton).toBeDefined(); + }); + + test("renders action bar with correct number of miners", () => { + const selectedMiners = ["MAC1", "MAC2", "MAC3"]; + const { getByText } = render(); + + const element = getByText(selectedMiners.length + " miners selected"); + expect(element).toBeInTheDocument(); + }); + + test("hides action bar when there are no miners", () => { + let selectedMiners = ["MAC1"]; + const { getByTestId, queryByTestId, rerender } = render( + , + ); + + expect(getByTestId(actionBarTestId)).toBeInTheDocument(); + + selectedMiners = []; + rerender(); + + expect(queryByTestId(actionBarTestId)).not.toBeInTheDocument(); + }); + + test("closes action bar on click of close button", () => { + const { getByTestId, queryByTestId } = render(); + + expect(getByTestId(actionBarTestId)).toBeInTheDocument(); + const closeButton = getByTestId("close-button"); + fireEvent.click(closeButton); + + expect(queryByTestId(actionBarTestId)).not.toBeInTheDocument(); + }); + + test("renders MinerActionsMenu and calls setHidden method properly", async () => { + const onActionStartMock = vi.fn(); + const selectedMiners = ["MinerId1"]; + + const { getByText, getByTestId } = render( + ( + { + onActionStartMock(); + setHidden(true); + }} + /> + )} + />, + ); + + expect(getByText("More")).toBeInTheDocument(); + + fireEvent.click(getByTestId("actions-menu-button")); + fireEvent.click(getByTestId("reboot-popover-button")); + expect(onActionStartMock).toHaveBeenCalled(); + }); + + test("calls onClose callback when close button is clicked", () => { + const onCloseMock = vi.fn(); + const { getByTestId } = render(); + + const closeButton = getByTestId("close-button"); + fireEvent.click(closeButton); + + expect(onCloseMock).toHaveBeenCalledOnce(); + }); + + test("does not throw error when onClose is not provided", () => { + const { getByTestId } = render(); + + const closeButton = getByTestId("close-button"); + + // Should not throw error when clicking close without onClose prop + expect(() => fireEvent.click(closeButton)).not.toThrow(); + }); + + test("renders selection controls only once", () => { + const onSelectAll = vi.fn(); + + render( + + Select all + + } + />, + ); + + const controls = screen.getAllByTestId("select-all-control"); + expect(controls).toHaveLength(1); + + fireEvent.click(controls[0]); + expect(onSelectAll).toHaveBeenCalledOnce(); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/ActionBar.tsx b/client/src/protoFleet/features/fleetManagement/components/ActionBar/ActionBar.tsx new file mode 100644 index 000000000..8c0e11621 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/ActionBar.tsx @@ -0,0 +1,101 @@ +import { ReactNode, useEffect, useMemo, useState } from "react"; +import clsx from "clsx"; +import { DismissTiny } from "@/shared/assets/icons"; +import Button, { variants } from "@/shared/components/Button"; +import { sizes } from "@/shared/components/ButtonGroup"; +import { type SelectionMode } from "@/shared/components/List"; + +interface ActionBarProps { + className?: string; + /** IDs of currently selected items (used for count display in "subset" mode) */ + selectedItems: string[]; + /** + * How items were selected: + * - "all": user clicked "Select All" with no filters (targets entire fleet) + * - "subset": user selected specific items or "Select All" with filters active + * - "none": no selection (ActionBar will be hidden) + * @default "subset" + */ + selectionMode?: SelectionMode; + /** + * Total number of items in the fleet. Used to display accurate count when + * selectionMode is "all", since selectedItems only contains visible page items. + */ + totalCount?: number; + selectionControls?: ReactNode; + renderActions: (setHidden: (hidden: boolean) => void) => ReactNode; + onClose?: () => void; +} + +const ActionBar = ({ + className, + selectedItems, + selectionMode = "subset", + totalCount, + selectionControls, + renderActions, + onClose, +}: ActionBarProps) => { + const [show, setShow] = useState(false); + const [hidden, setHidden] = useState(false); + + useEffect(() => { + setShow(selectedItems.length > 0); + }, [selectedItems]); + + const selectionText = useMemo(() => { + const count = selectionMode === "all" ? (totalCount ?? selectedItems.length) : selectedItems.length; + return `${count} miner${count === 1 ? "" : "s"} selected`; + }, [selectionMode, selectedItems.length, totalCount]); + + const handleClose = () => { + setShow(false); + onClose?.(); + }; + + if (!show) { + return null; + } + + const actionsClassName = clsx( + "ml-auto flex items-center justify-end gap-3", + "phone:col-start-2 phone:row-start-2 phone:ml-0 phone:justify-end", + "tablet:col-start-2 tablet:row-start-2 tablet:ml-0 tablet:justify-end", + selectionControls ? "" : "phone:col-span-2 tablet:col-span-2", + ); + + return ( +
+
+
+
+ {selectionControls ? ( +
+ {selectionControls} +
+ ) : null} +
{renderActions(setHidden)}
+
+
+ ); +}; + +export default ActionBar; diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/FleetPoolActionsMenu.tsx b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/FleetPoolActionsMenu.tsx new file mode 100644 index 000000000..5f3488035 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/FleetPoolActionsMenu.tsx @@ -0,0 +1,92 @@ +import { useCallback, useState } from "react"; +import { Ellipsis } from "@/shared/assets/icons"; +import { iconSizes } from "@/shared/assets/icons/constants"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import Popover, { popoverSizes } from "@/shared/components/Popover"; +import { PopoverProvider, usePopover } from "@/shared/components/Popover"; +import Row from "@/shared/components/Row"; +import { positions } from "@/shared/constants"; +import { useClickOutside } from "@/shared/hooks/useClickOutside"; + +interface FleetPoolActionsMenuProps { + onTestConnection: () => void; + onRemove: () => void; + poolId: string; +} + +const FleetPoolActionsMenuInner = ({ onTestConnection, onRemove, poolId }: FleetPoolActionsMenuProps) => { + const [isOpen, setIsOpen] = useState(false); + const { triggerRef } = usePopover(); + + const onClickOutside = useCallback(() => { + setIsOpen(false); + }, []); + + useClickOutside({ + ref: triggerRef, + onClickOutside, + ignoreSelectors: [".popover-content"], + }); + + const handleTestConnection = useCallback(() => { + setIsOpen(false); + onTestConnection(); + }, [onTestConnection]); + + const handleRemove = useCallback(() => { + setIsOpen(false); + onRemove(); + }, [onRemove]); + + return ( +
+
+ ); +}; + +const FleetPoolActionsMenu = (props: FleetPoolActionsMenuProps) => ( + + + +); + +export default FleetPoolActionsMenu; diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/FleetPoolRow.tsx b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/FleetPoolRow.tsx new file mode 100644 index 000000000..e70cee438 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/FleetPoolRow.tsx @@ -0,0 +1,76 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import FleetPoolActionsMenu from "./FleetPoolActionsMenu"; +import { MiningPool } from "./types"; +import { Grip } from "@/shared/assets/icons"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import Row from "@/shared/components/Row"; + +interface FleetPoolRowProps { + pool: MiningPool; + priorityNumber: number; + onUpdate: () => void; + onTestConnection: () => void; + onRemove: () => void; + testId?: string; +} + +const FleetPoolRow = ({ pool, priorityNumber, onUpdate, onTestConnection, onRemove, testId }: FleetPoolRowProps) => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: pool.poolId }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + const displayTitle = pool.name || pool.poolUrl || "—"; + + return ( +
+ +
+ {/* Priority number */} +
+ {priorityNumber} +
+ + {/* Drag handle */} +
+ +
+ + {/* Pool info */} +
+
+ {displayTitle} +
+
+ {pool.poolUrl} +
+
+
+ +
+
+
+
+ ); +}; + +export default FleetPoolRow; diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionModal/PoolSelectionModal.stories.tsx b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionModal/PoolSelectionModal.stories.tsx new file mode 100644 index 000000000..53d321c89 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionModal/PoolSelectionModal.stories.tsx @@ -0,0 +1,35 @@ +import { useState } from "react"; +import { action } from "storybook/actions"; +import PoolSelectionModal from "./PoolSelectionModal"; + +export default { + title: "Proto Fleet/Fleet Management/PoolSelectionModal", + component: PoolSelectionModal, +}; + +export const Default = () => { + const [open, setOpen] = useState(true); + + return ( + <> + {!open && ( +
+ +
+ )} + { + action("onDismiss")(); + setOpen(false); + }} + onSave={(selectedPoolId, poolData) => { + action("onSave")({ selectedPoolId, poolData }); + setOpen(false); + }} + /> + + ); +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionModal/PoolSelectionModal.test.tsx b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionModal/PoolSelectionModal.test.tsx new file mode 100644 index 000000000..0c96b7dcb --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionModal/PoolSelectionModal.test.tsx @@ -0,0 +1,300 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import PoolSelectionModal from "./PoolSelectionModal"; +import { PoolSchema } from "@/protoFleet/api/generated/pools/v1/pools_pb"; +import usePools from "@/protoFleet/api/usePools"; + +vi.mock("@/protoFleet/api/usePools"); + +describe("PoolSelectionModal", () => { + const mockPools = [ + create(PoolSchema, { + poolId: BigInt(1), + poolName: "Ocean Pool", + url: "stratum+tcp://mine.ocean.xyz:3334", + username: "ocean_user", + }), + create(PoolSchema, { + poolId: BigInt(2), + poolName: "Braiins Pool", + url: "stratum+tcp://stratum.braiins.com:3333", + username: "braiins_user", + }), + create(PoolSchema, { + poolId: BigInt(3), + poolName: "Foundry USA", + url: "stratum+tcp://stratum.foundryusapool.com:3333", + username: "foundry_user", + }), + ]; + + const mockValidatePool = vi.fn(); + const mockCreatePool = vi.fn(); + const onDismiss = vi.fn(); + const onSave = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(usePools).mockReturnValue({ + pools: mockPools, + miningPools: mockPools.map((pool) => ({ + poolId: pool.poolId.toString(), + name: pool.poolName, + poolUrl: pool.url, + username: pool.username, + })), + validatePool: mockValidatePool, + createPool: mockCreatePool, + updatePool: vi.fn(), + deletePool: vi.fn(), + validatePoolPending: false, + isLoading: false, + }); + }); + + test("renders modal with pool list", () => { + const { getByText } = render(); + + expect(getByText("Select pool")).toBeInTheDocument(); + expect(getByText("Ocean Pool")).toBeInTheDocument(); + expect(getByText("Braiins Pool")).toBeInTheDocument(); + expect(getByText("Foundry USA")).toBeInTheDocument(); + }); + + test("renders search input", () => { + const { getByTestId } = render(); + + const searchInput = getByTestId("pool-search-input"); + expect(searchInput).toBeInTheDocument(); + }); + + test("autofocuses the search input on mount", () => { + const { getByTestId } = render(); + + const searchInput = getByTestId("pool-search-input"); + expect(searchInput).toHaveFocus(); + }); + + test("filters pools by name", () => { + const { getByTestId, getByText, queryByText } = render( + , + ); + + const searchInput = getByTestId("pool-search-input"); + fireEvent.change(searchInput, { target: { value: "ocean" } }); + + expect(getByText("Ocean Pool")).toBeInTheDocument(); + expect(queryByText("Braiins Pool")).not.toBeInTheDocument(); + expect(queryByText("Foundry USA")).not.toBeInTheDocument(); + }); + + test("filters pools by URL", () => { + const { getByTestId, getByText, queryByText } = render( + , + ); + + const searchInput = getByTestId("pool-search-input"); + fireEvent.change(searchInput, { target: { value: "braiins.com" } }); + + expect(queryByText("Ocean Pool")).not.toBeInTheDocument(); + expect(getByText("Braiins Pool")).toBeInTheDocument(); + expect(queryByText("Foundry USA")).not.toBeInTheDocument(); + }); + + test("filters pools by username", () => { + const { getByTestId, getByText, queryByText } = render( + , + ); + + const searchInput = getByTestId("pool-search-input"); + fireEvent.change(searchInput, { target: { value: "foundry_user" } }); + + expect(queryByText("Ocean Pool")).not.toBeInTheDocument(); + expect(queryByText("Braiins Pool")).not.toBeInTheDocument(); + expect(getByText("Foundry USA")).toBeInTheDocument(); + }); + + test("shows 'No pools found' when search returns no results", () => { + const { getByTestId, getByText } = render(); + + const searchInput = getByTestId("pool-search-input"); + fireEvent.change(searchInput, { target: { value: "nonexistent" } }); + + expect(getByText("No pools found")).toBeInTheDocument(); + }); + + test("selecting a pool and clicking Save calls onSave with pool ID", () => { + const { getByText } = render(); + + const poolRow = getByText("Ocean Pool"); + fireEvent.click(poolRow); + + const saveButton = getByText("Save"); + fireEvent.click(saveButton); + + expect(onSave).toHaveBeenCalledWith("1"); + }); + + test("Save button is disabled when no pool is selected", () => { + const { getByText } = render(); + + const saveButton = getByText("Save").closest("button"); + expect(saveButton).toBeDisabled(); + }); + + test("Save button is enabled when a pool is selected", () => { + const { getByText } = render(); + + const poolRow = getByText("Ocean Pool"); + fireEvent.click(poolRow); + + const saveButton = getByText("Save").closest("button"); + expect(saveButton).not.toBeDisabled(); + }); + + test("clicking 'Add new pool' button opens PoolModal", () => { + const { getByText } = render(); + + const addNewPoolButton = getByText("Add new pool"); + fireEvent.click(addNewPoolButton); + + expect(getByText("Save")).toBeInTheDocument(); + expect(getByText("Worker name will be appended to this username when applied to miners.")).toBeInTheDocument(); + }); + + test("rejects usernames with workername separators when adding a new pool", () => { + render(); + + fireEvent.click(screen.getByText("Add new pool")); + + fireEvent.change(screen.getByTestId("pool-name-0-input"), { target: { value: "Test Pool" } }); + fireEvent.change(screen.getByTestId("url-0-input"), { target: { value: "stratum+tcp://test.com:3333" } }); + fireEvent.change(screen.getByTestId("username-0-input"), { target: { value: "wallet.worker01" } }); + + fireEvent.click(screen.getByTestId("pool-save-button")); + + expect(mockCreatePool).not.toHaveBeenCalled(); + expect( + screen.getByText("Fleet-level pool usernames can’t include periods (.). Set worker names on each miner instead."), + ).toBeInTheDocument(); + }); + + test("renders pool data in correct columns", () => { + const { getByText } = render(); + + // Check column headers + expect(getByText("Name")).toBeInTheDocument(); + expect(getByText("URL")).toBeInTheDocument(); + expect(getByText("Username")).toBeInTheDocument(); + + // Check pool data is displayed + expect(getByText("Ocean Pool")).toBeInTheDocument(); + expect(getByText("stratum+tcp://mine.ocean.xyz:3334")).toBeInTheDocument(); + expect(getByText("ocean_user")).toBeInTheDocument(); + }); + + test("search is case insensitive", () => { + const { getByTestId, getByText, queryByText } = render( + , + ); + + const searchInput = getByTestId("pool-search-input"); + fireEvent.change(searchInput, { target: { value: "OCEAN" } }); + + expect(getByText("Ocean Pool")).toBeInTheDocument(); + expect(queryByText("Braiins Pool")).not.toBeInTheDocument(); + }); + + test("clearing search shows all pools again", () => { + const { getByTestId, getByText } = render(); + + const searchInput = getByTestId("pool-search-input"); + + // First filter + fireEvent.change(searchInput, { target: { value: "ocean" } }); + expect(getByText("Ocean Pool")).toBeInTheDocument(); + + // Clear filter + fireEvent.change(searchInput, { target: { value: "" } }); + expect(getByText("Ocean Pool")).toBeInTheDocument(); + expect(getByText("Braiins Pool")).toBeInTheDocument(); + expect(getByText("Foundry USA")).toBeInTheDocument(); + }); + + test("shows success callout when test connection succeeds", async () => { + mockValidatePool.mockImplementation(({ onSuccess, onFinally }) => { + onSuccess?.(); + onFinally?.(); + }); + + const { getByText, getByTestId } = render(); + + // Select a pool + fireEvent.click(getByText("Ocean Pool")); + + // Click test connection + fireEvent.click(getByText("Test connection")); + + // Success callout should appear and be visible + await waitFor(() => { + const callout = getByTestId("pool-selection-modal-connection-success-callout"); + expect(callout).toHaveClass("max-h-96"); + expect(callout).not.toHaveClass("max-h-0"); + }); + expect(getByText("Pool connection successful")).toBeInTheDocument(); + }); + + test("shows error callout when test connection fails", async () => { + mockValidatePool.mockImplementation(({ onError, onFinally }) => { + onError?.(); + onFinally?.(); + }); + + const { getByText, getByTestId } = render(); + + // Select a pool + fireEvent.click(getByText("Ocean Pool")); + + // Click test connection + fireEvent.click(getByText("Test connection")); + + // Error callout should appear and be visible + await waitFor(() => { + const callout = getByTestId("pool-selection-modal-connection-error-callout"); + expect(callout).toHaveClass("max-h-96"); + expect(callout).not.toHaveClass("max-h-0"); + }); + expect( + getByText("We couldn't connect with your pool. Review your pool details and try again."), + ).toBeInTheDocument(); + }); + + test("dismisses callout when selecting a different pool", async () => { + mockValidatePool.mockImplementation(({ onSuccess, onFinally }) => { + onSuccess?.(); + onFinally?.(); + }); + + const { getByText, getByTestId } = render(); + + // Select a pool and test connection + fireEvent.click(getByText("Ocean Pool")); + fireEvent.click(getByText("Test connection")); + + // Wait for success callout to appear + await waitFor(() => { + const callout = getByTestId("pool-selection-modal-connection-success-callout"); + expect(callout).toHaveClass("max-h-96"); + }); + + // Select a different pool + fireEvent.click(getByText("Braiins Pool")); + + // Callout should be hidden + await waitFor(() => { + const callout = getByTestId("pool-selection-modal-connection-success-callout"); + expect(callout).toHaveClass("max-h-0"); + }); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionModal/PoolSelectionModal.tsx b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionModal/PoolSelectionModal.tsx new file mode 100644 index 000000000..011d08754 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionModal/PoolSelectionModal.tsx @@ -0,0 +1,346 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { create } from "@bufbuild/protobuf"; +import { MiningPool } from "../types"; +import { CreatePoolRequestSchema } from "@/protoFleet/api/generated/pools/v1/pools_pb"; +import usePools from "@/protoFleet/api/usePools"; +import { Alert, Success } from "@/shared/assets/icons"; +import { iconSizes } from "@/shared/assets/icons/constants"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import { DismissibleCalloutWrapper, intents } from "@/shared/components/Callout"; +import Input from "@/shared/components/Input"; +import { emptyPoolInfo } from "@/shared/components/MiningPools/constants"; +import { fleetUsernameHelperText } from "@/shared/components/MiningPools/PoolForm/constants"; +import PoolModal from "@/shared/components/MiningPools/PoolModal"; +import { PoolInfo } from "@/shared/components/MiningPools/types"; +import Modal from "@/shared/components/Modal"; +import Radio from "@/shared/components/Radio"; + +const filterPoolsByQuery = (pools: MiningPool[], query: string): MiningPool[] => { + const lowerQuery = query.toLowerCase(); + return pools.filter( + (pool) => + pool.name.toLowerCase().includes(lowerQuery) || + pool.poolUrl.toLowerCase().includes(lowerQuery) || + pool.username.toLowerCase().includes(lowerQuery), + ); +}; + +interface PoolSelectableRowProps { + pool: MiningPool; + isSelected: boolean; + isDisabled: boolean; + onSelect?: () => void; + testId: string; +} + +const PoolSelectableRow = ({ pool, isSelected, isDisabled, onSelect, testId }: PoolSelectableRowProps) => ( +
!isDisabled && onSelect?.()} + data-testid={testId} + aria-disabled={isDisabled} + > +
+ +
+
+ {pool.name} +
+
+ {pool.poolUrl} +
+
+ {pool.username} +
+
+); + +interface PoolSelectionModalProps { + open?: boolean; + onDismiss: () => void; + onSave: (selectedPoolId: string, poolData?: MiningPool) => void; + excludedPoolIds?: (string | undefined)[]; + unknownPools?: MiningPool[]; +} + +const PoolSelectionModal = ({ + open, + onDismiss, + onSave, + excludedPoolIds = [], + unknownPools = [], +}: PoolSelectionModalProps) => { + const isVisible = open ?? true; + const [selectedPoolId, setSelectedPoolId] = useState(); + const [searchQuery, setSearchQuery] = useState(""); + const [showAddPoolModal, setShowAddPoolModal] = useState(false); + const [newPoolInfo, setNewPoolInfo] = useState([emptyPoolInfo]); + const [isTestingConnection, setIsTestingConnection] = useState(false); + const [showConnectionCallout, setShowConnectionCallout] = useState(false); + const [connectionError, setConnectionError] = useState(false); + + const { validatePool, createPool, miningPools } = usePools(isVisible); + + // Reset internal state when hidden to mirror prior conditional-mount behavior. + /* eslint-disable react-hooks/set-state-in-effect */ + useEffect(() => { + if (isVisible) { + return; + } + + setSelectedPoolId(undefined); + setSearchQuery(""); + setShowAddPoolModal(false); + setNewPoolInfo([emptyPoolInfo]); + setIsTestingConnection(false); + setShowConnectionCallout(false); + setConnectionError(false); + }, [isVisible]); + /* eslint-enable react-hooks/set-state-in-effect */ + + const showSuccessCallout = useMemo( + () => showConnectionCallout && !isTestingConnection && !connectionError, + [showConnectionCallout, isTestingConnection, connectionError], + ); + + const showErrorCallout = useMemo( + () => showConnectionCallout && !isTestingConnection && connectionError, + [showConnectionCallout, isTestingConnection, connectionError], + ); + + const filteredPools = useMemo(() => filterPoolsByQuery(miningPools, searchQuery), [miningPools, searchQuery]); + + const filteredUnknownPools = useMemo( + () => filterPoolsByQuery(unknownPools, searchQuery), + [unknownPools, searchQuery], + ); + + const isPoolExcluded = (poolId: string) => excludedPoolIds.includes(poolId); + + const handleSave = () => { + if (selectedPoolId) { + onSave(selectedPoolId); + } + }; + + const handleTestSelectedConnection = useCallback(() => { + if (!selectedPoolId) return; + + const selectedPool = miningPools.find((p) => p.poolId === selectedPoolId); + if (!selectedPool) return; + + setIsTestingConnection(true); + setConnectionError(false); + validatePool({ + poolInfo: { + url: selectedPool.poolUrl, + username: selectedPool.username, + }, + onSuccess: () => { + setConnectionError(false); + }, + onError: () => { + setConnectionError(true); + }, + onFinally: () => { + setIsTestingConnection(false); + setShowConnectionCallout(true); + }, + }); + }, [selectedPoolId, miningPools, validatePool]); + + const handleNewPoolSave = async (pool: PoolInfo, isPasswordSet: boolean) => { + const createPoolRequest = create(CreatePoolRequestSchema, { + poolConfig: { + poolName: pool.name || "", + url: pool.url || "", + username: pool.username || "", + password: isPasswordSet && pool.password ? pool.password : "", + }, + }); + + return new Promise((resolve, reject) => { + createPool({ + createPoolRequest, + onSuccess: (poolId) => { + setShowAddPoolModal(false); + + const newPoolData: MiningPool = { + poolId: poolId, + name: pool.name || "", + poolUrl: pool.url || "", + username: pool.username || "", + }; + + onSave(poolId, newPoolData); + resolve(); + }, + onError: (error) => { + reject(new Error(error)); + }, + }); + }); + }; + + const handlePoolModalDismiss = () => { + setShowAddPoolModal(false); + setNewPoolInfo([emptyPoolInfo]); + }; + + const handleTestConnection = (args: { + poolInfo: PoolInfo; + onError?: (error?: string) => void; + onSuccess?: () => void; + onFinally?: () => void; + }) => { + setIsTestingConnection(true); + validatePool({ + poolInfo: { + url: args.poolInfo.url, + username: args.poolInfo.username, + password: args.poolInfo.password, + }, + onSuccess: () => { + args.onSuccess?.(); + }, + onError: (error) => { + args.onError?.(error); + }, + onFinally: () => { + setIsTestingConnection(false); + args.onFinally?.(); + }, + }); + }; + + if (showAddPoolModal) { + return ( + + ); + } + + return ( + +
+ } + intent={intents.success} + onDismiss={() => setShowConnectionCallout(false)} + show={showSuccessCallout} + title="Pool connection successful" + testId="pool-selection-modal-connection-success-callout" + /> + } + intent={intents.danger} + onDismiss={() => setShowConnectionCallout(false)} + show={showErrorCallout} + title="We couldn't connect with your pool. Review your pool details and try again." + testId="pool-selection-modal-connection-error-callout" + /> +
+ setSearchQuery(value)} + dismiss + testId="pool-search-input" + className="h-12" + autoFocus + /> +
+ + {/* Add new pool button */} +
+
+ +
+
+
+
Name
+
URL
+
Username
+
+ +
+ {filteredPools.length === 0 && filteredUnknownPools.length === 0 && searchQuery ? ( +
No pools found
+ ) : ( + <> + {filteredPools.map((pool) => ( + { + setSelectedPoolId(pool.poolId); + setShowConnectionCallout(false); + }} + testId={`pool-row-${pool.name}`} + /> + ))} + {filteredUnknownPools.map((pool) => ( + + ))} + + )} +
+
+
+
+ ); +}; + +export default PoolSelectionModal; diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionModal/index.ts b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionModal/index.ts new file mode 100644 index 000000000..19c8cfe92 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionModal/index.ts @@ -0,0 +1 @@ +export { default } from "./PoolSelectionModal"; diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionPage.stories.tsx b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionPage.stories.tsx new file mode 100644 index 000000000..3d2facd2a --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionPage.stories.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from "react"; +import PoolSelectionPageComponent from "./PoolSelectionPage"; +import { MockedPoolApis } from "@/protoFleet/stories/MockedPoolApis"; + +const withMockedPoolApis = (Story: () => ReactNode) => ( + + + +); + +interface PoolSelectionPageArgs { + numberOfMiners: number; +} + +export const PoolSelectionPage = ({ numberOfMiners }: PoolSelectionPageArgs) => { + const deviceIdentifiers = Array.from({ length: numberOfMiners }, (_, i) => `device-${i}`); + + return ( + {}} + onDismiss={() => {}} + /> + ); +}; + +export default { + title: "Proto Fleet/Action Bar/Settings widget/Pool selection page", + decorators: [withMockedPoolApis], + args: { + numberOfMiners: 1, + }, + argTypes: { + numberOfMiners: { + control: { type: "range", min: 1, max: 25, step: 1 }, + }, + }, +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionPage.test.tsx b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionPage.test.tsx new file mode 100644 index 000000000..2eec7a528 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionPage.test.tsx @@ -0,0 +1,518 @@ +import { fireEvent, render, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import PoolSelectionPage from "./PoolSelectionPage"; +import { PoolSchema } from "@/protoFleet/api/generated/pools/v1/pools_pb"; + +const mockPools = [ + create(PoolSchema, { + poolId: BigInt(1), + poolName: "Client pool A1", + url: "stratum+tcp://mine.ocean.xyz:3323", + username: "user1", + }), + create(PoolSchema, { + poolId: BigInt(2), + poolName: "Client pool A2", + url: "stratum+tcp://mine.ocean.xyz:3324", + username: "user2", + }), + create(PoolSchema, { + poolId: BigInt(3), + poolName: "Client pool A3", + url: "stratum+tcp://mine.ocean.xyz:3325", + username: "user3", + }), +]; + +const mockValidatePool = vi.fn(({ onSuccess, onFinally }) => { + onSuccess?.(); + onFinally?.(); +}); +const mockFetchPoolAssignments = vi.fn().mockResolvedValue([]); + +vi.mock("@/protoFleet/api/usePools", () => ({ + default: () => ({ + pools: mockPools, + miningPools: mockPools.map((pool) => ({ + poolId: pool.poolId.toString(), + name: pool.poolName, + poolUrl: pool.url, + username: pool.username, + })), + validatePool: mockValidatePool, + createPool: vi.fn(), + updatePool: vi.fn(), + deletePool: vi.fn(), + validatePoolPending: false, + }), +})); + +vi.mock("@/protoFleet/api/useMinerPoolAssignments", () => ({ + default: () => ({ + fetchPoolAssignments: mockFetchPoolAssignments, + isLoading: false, + }), +})); + +describe("Pool selection page", () => { + const numberOfMiners = 5; + const deviceIdentifiers = Array.from({ length: numberOfMiners }, (_, i) => `device-${i}`); + + const onCancel = vi.fn(); + const onAssignPools = vi.fn().mockResolvedValue(undefined); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("renders page with Add pool button when no pools configured", () => { + const { getByText, getByTestId } = render( + , + ); + + expect(getByText("Assign pools")).toBeInTheDocument(); + expect(getByTestId("add-pool-button")).toBeInTheDocument(); + expect(getByText("Add pool")).toBeInTheDocument(); + }); + + test("renders correct number of miners in button text", () => { + const { getByText } = render( + , + ); + + expect(getByText(`Assign to ${numberOfMiners} miners`)).toBeInTheDocument(); + }); + + test("uses numberOfMiners override when provided (Select All scenario)", () => { + // Simulates the "Select All" scenario where: + // - deviceIdentifiers contains only 50 visible miners from pagination + // - numberOfMiners is the actual total count (e.g., 297) + const visibleDeviceIdentifiers = Array.from({ length: 50 }, (_, i) => `device-${i}`); + const totalMinerCount = 297; + + const { getByText } = render( + , + ); + + // Should show the override count (297), not the deviceIdentifiers length (50) + expect(getByText(`Assign to ${totalMinerCount} miners`)).toBeInTheDocument(); + }); + + test("disables assign button when no pools are configured", async () => { + const { getByText } = render( + , + ); + + const assignButton = getByText(`Assign to ${numberOfMiners} miners`).closest("button"); + expect(assignButton).toBeDisabled(); + }); + + test("calls onCancel when close button clicked", async () => { + const { getAllByTestId } = render( + , + ); + + const closeModalButton = getAllByTestId("header-icon-button")[0]; + fireEvent.click(closeModalButton); + await waitFor(() => { + expect(onCancel).toHaveBeenCalled(); + }); + }); + + test("does not handle Escape when page is hidden", () => { + render( + , + ); + + fireEvent.keyDown(document, { key: "Escape" }); + + expect(onCancel).not.toHaveBeenCalled(); + }); + + test("loads assignments only after page becomes visible", async () => { + const singleDevice = ["device-1"]; + + const { rerender } = render( + , + ); + + expect(mockFetchPoolAssignments).not.toHaveBeenCalled(); + + rerender( + , + ); + + await waitFor(() => { + expect(mockFetchPoolAssignments).toHaveBeenCalledWith("device-1"); + }); + }); + + test("opens selection modal when Add pool button is clicked", async () => { + const { getByText, getByTestId } = render( + , + ); + + const addPoolButton = getByTestId("add-pool-button"); + fireEvent.click(addPoolButton); + + await waitFor(() => { + expect(getByText("Select pool")).toBeInTheDocument(); + }); + }); + + test("adds pool to list when selected from modal", async () => { + const { getByText, getByTestId, getAllByText } = render( + , + ); + + // Click Add pool button + fireEvent.click(getByTestId("add-pool-button")); + + await waitFor(() => { + expect(getByText("Select pool")).toBeInTheDocument(); + }); + + // Select a pool from the modal + fireEvent.click(getByText("Client pool A1")); + + // Click Save button + const saveButton = getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement; + fireEvent.click(saveButton); + + // Pool should be added to the list + await waitFor(() => { + expect(getByTestId("pool-row-0")).toBeInTheDocument(); + }); + + // Should show "Add another pool" button since we can add more + expect(getByTestId("add-another-pool-button")).toBeInTheDocument(); + }); + + test("shows Update button for each configured pool", async () => { + const { getByText, getByTestId, getAllByText } = render( + , + ); + + // Add first pool + fireEvent.click(getByTestId("add-pool-button")); + await waitFor(() => expect(getByText("Select pool")).toBeInTheDocument()); + fireEvent.click(getByText("Client pool A1")); + fireEvent.click(getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement); + + await waitFor(() => { + expect(getByTestId("pool-row-0")).toBeInTheDocument(); + }); + + // Add second pool + fireEvent.click(getByTestId("add-another-pool-button")); + await waitFor(() => expect(getByText("Select pool")).toBeInTheDocument()); + fireEvent.click(getByText("Client pool A2")); + fireEvent.click(getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement); + + await waitFor(() => { + expect(getByTestId("pool-row-1")).toBeInTheDocument(); + }); + + // Both pools should have Update buttons + expect(getAllByText("Update").length).toBe(2); + }); + + test("enables assign button after adding a pool", async () => { + const { getByText, getByTestId, getAllByText } = render( + , + ); + + // Assign button should be disabled initially + const assignButton = getByText(`Assign to ${numberOfMiners} miners`).closest("button"); + expect(assignButton).toBeDisabled(); + + // Add a pool + fireEvent.click(getByTestId("add-pool-button")); + await waitFor(() => expect(getByText("Select pool")).toBeInTheDocument()); + fireEvent.click(getByText("Client pool A1")); + fireEvent.click(getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement); + + // Wait for pool to be added + await waitFor(() => { + expect(getByTestId("pool-row-0")).toBeInTheDocument(); + }); + + // Assign button should be enabled now + expect(assignButton).not.toBeDisabled(); + }); + + test("hides Add another pool button when 3 pools are configured", async () => { + const { getByText, getByTestId, getAllByText, queryByTestId } = render( + , + ); + + // Add first pool + fireEvent.click(getByTestId("add-pool-button")); + await waitFor(() => expect(getByText("Select pool")).toBeInTheDocument()); + fireEvent.click(getByText("Client pool A1")); + fireEvent.click(getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement); + await waitFor(() => expect(getByTestId("pool-row-0")).toBeInTheDocument()); + + // Add second pool + fireEvent.click(getByTestId("add-another-pool-button")); + await waitFor(() => expect(getByText("Select pool")).toBeInTheDocument()); + fireEvent.click(getByText("Client pool A2")); + fireEvent.click(getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement); + await waitFor(() => expect(getByTestId("pool-row-1")).toBeInTheDocument()); + + // Add third pool + fireEvent.click(getByTestId("add-another-pool-button")); + await waitFor(() => expect(getByText("Select pool")).toBeInTheDocument()); + fireEvent.click(getByText("Client pool A3")); + fireEvent.click(getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement); + await waitFor(() => expect(getByTestId("pool-row-2")).toBeInTheDocument()); + + // "Add another pool" button should not be visible + expect(queryByTestId("add-another-pool-button")).not.toBeInTheDocument(); + }); + + test("calls onAssignPools with correct pool IDs when assign button clicked", async () => { + const { getByText, getByTestId, getAllByText } = render( + , + ); + + // Add first pool + fireEvent.click(getByTestId("add-pool-button")); + await waitFor(() => expect(getByText("Select pool")).toBeInTheDocument()); + fireEvent.click(getByText("Client pool A1")); + fireEvent.click(getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement); + await waitFor(() => expect(getByTestId("pool-row-0")).toBeInTheDocument()); + + // Add second pool + fireEvent.click(getByTestId("add-another-pool-button")); + await waitFor(() => expect(getByText("Select pool")).toBeInTheDocument()); + fireEvent.click(getByText("Client pool A2")); + fireEvent.click(getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement); + await waitFor(() => expect(getByTestId("pool-row-1")).toBeInTheDocument()); + + // Click assign button + const assignButton = getByText(`Assign to ${numberOfMiners} miners`).closest("button") as HTMLElement; + fireEvent.click(assignButton); + + await waitFor(() => { + expect(onAssignPools).toHaveBeenCalledWith({ + defaultPool: { type: "poolId", poolId: "1" }, + backup1Pool: { type: "poolId", poolId: "2" }, + backup2Pool: undefined, + }); + }); + }); + + test("shows priority numbers in pool list", async () => { + const { getByText, getByTestId, getAllByText } = render( + , + ); + + // Add first pool + fireEvent.click(getByTestId("add-pool-button")); + await waitFor(() => expect(getByText("Select pool")).toBeInTheDocument()); + fireEvent.click(getByText("Client pool A1")); + fireEvent.click(getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement); + await waitFor(() => expect(getByTestId("pool-row-0")).toBeInTheDocument()); + + // Add second pool + fireEvent.click(getByTestId("add-another-pool-button")); + await waitFor(() => expect(getByText("Select pool")).toBeInTheDocument()); + fireEvent.click(getByText("Client pool A2")); + fireEvent.click(getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement); + await waitFor(() => expect(getByTestId("pool-row-1")).toBeInTheDocument()); + + // Check priority numbers are displayed + const poolRow0 = getByTestId("pool-row-0"); + const poolRow1 = getByTestId("pool-row-1"); + + expect(poolRow0).toHaveTextContent("1"); + expect(poolRow1).toHaveTextContent("2"); + }); + + test("shows Add new pool button in selection modal", async () => { + const { getByText, getByTestId } = render( + , + ); + + fireEvent.click(getByTestId("add-pool-button")); + + await waitFor(() => { + expect(getByText("Select pool")).toBeInTheDocument(); + }); + + expect(getByTestId("add-new-pool-button")).toBeInTheDocument(); + expect(getByText("Add new pool")).toBeInTheDocument(); + }); + + test("shows success callout when test connection succeeds", async () => { + const { getByText, getByTestId, getAllByText } = render( + , + ); + + // Add a pool first + fireEvent.click(getByTestId("add-pool-button")); + await waitFor(() => expect(getByText("Select pool")).toBeInTheDocument()); + fireEvent.click(getByText("Client pool A1")); + fireEvent.click(getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement); + await waitFor(() => expect(getByTestId("pool-row-0")).toBeInTheDocument()); + + // Click test connection via the actions menu + const actionsButton = getByTestId("pool-1-actions-menu-button"); + fireEvent.click(actionsButton); + + await waitFor(() => { + expect(getByTestId("pool-1-test-connection-action")).toBeInTheDocument(); + }); + + fireEvent.click(getByTestId("pool-1-test-connection-action")); + + // Success callout should appear and be visible (max-h-96) + await waitFor(() => { + const callout = getByTestId("pool-selection-page-connection-success-callout"); + expect(callout).toHaveClass("max-h-96"); + expect(callout).not.toHaveClass("max-h-0"); + expect(getByText("Pool connection successful")).toBeInTheDocument(); + }); + }); + + test("dismisses success callout when dismiss button is clicked", async () => { + const { getByText, getByTestId, getAllByText } = render( + , + ); + + // Add a pool first + fireEvent.click(getByTestId("add-pool-button")); + await waitFor(() => expect(getByText("Select pool")).toBeInTheDocument()); + fireEvent.click(getByText("Client pool A1")); + fireEvent.click(getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement); + await waitFor(() => expect(getByTestId("pool-row-0")).toBeInTheDocument()); + + // Click test connection via the actions menu + const actionsButton = getByTestId("pool-1-actions-menu-button"); + fireEvent.click(actionsButton); + + await waitFor(() => { + expect(getByTestId("pool-1-test-connection-action")).toBeInTheDocument(); + }); + + fireEvent.click(getByTestId("pool-1-test-connection-action")); + + // Success callout should appear with max-h-96 (visible state) + await waitFor(() => { + const callout = getByTestId("pool-selection-page-connection-success-callout"); + expect(callout).toHaveClass("max-h-96"); + }); + + // Find and click the dismiss button within the callout + const callout = getByTestId("pool-selection-page-connection-success-callout"); + const dismissButton = callout.querySelector("button"); + if (dismissButton) { + fireEvent.click(dismissButton); + } + + // Callout should be hidden (max-h-0 class) + await waitFor(() => { + const calloutAfter = getByTestId("pool-selection-page-connection-success-callout"); + expect(calloutAfter).toHaveClass("max-h-0"); + }); + }); + + test("shows error callout when test connection fails", async () => { + // Override mock to simulate failure + mockValidatePool.mockImplementationOnce(({ onError, onFinally }) => { + onError?.(); + onFinally?.(); + }); + + const { getByText, getByTestId, getAllByText } = render( + , + ); + + // Add a pool first + fireEvent.click(getByTestId("add-pool-button")); + await waitFor(() => expect(getByText("Select pool")).toBeInTheDocument()); + fireEvent.click(getByText("Client pool A1")); + fireEvent.click(getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement); + await waitFor(() => expect(getByTestId("pool-row-0")).toBeInTheDocument()); + + // Click test connection via the actions menu + const actionsButton = getByTestId("pool-1-actions-menu-button"); + fireEvent.click(actionsButton); + + await waitFor(() => { + expect(getByTestId("pool-1-test-connection-action")).toBeInTheDocument(); + }); + + fireEvent.click(getByTestId("pool-1-test-connection-action")); + + // Error callout should appear and be visible (max-h-96) + await waitFor(() => { + const callout = getByTestId("pool-selection-page-connection-error-callout"); + expect(callout).toHaveClass("max-h-96"); + expect(callout).not.toHaveClass("max-h-0"); + expect( + getByText("We couldn't connect with your pool. Review your pool details and try again."), + ).toBeInTheDocument(); + }); + }); + + test("dismisses callout when opening pool selection modal", async () => { + const { getByText, getByTestId, getAllByText } = render( + , + ); + + // Add a pool first + fireEvent.click(getByTestId("add-pool-button")); + await waitFor(() => expect(getByText("Select pool")).toBeInTheDocument()); + fireEvent.click(getByText("Client pool A1")); + fireEvent.click(getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement); + await waitFor(() => expect(getByTestId("pool-row-0")).toBeInTheDocument()); + + // Click test connection via the actions menu + const actionsButton = getByTestId("pool-1-actions-menu-button"); + fireEvent.click(actionsButton); + + await waitFor(() => { + expect(getByTestId("pool-1-test-connection-action")).toBeInTheDocument(); + }); + + fireEvent.click(getByTestId("pool-1-test-connection-action")); + + // Success callout should appear with max-h-96 (visible state) + await waitFor(() => { + const callout = getByTestId("pool-selection-page-connection-success-callout"); + expect(callout).toHaveClass("max-h-96"); + }); + + // Open pool selection modal (Add another pool) + fireEvent.click(getByTestId("add-another-pool-button")); + + // Callout should be hidden (max-h-0 class) when modal opens + await waitFor(() => { + const callout = getByTestId("pool-selection-page-connection-success-callout"); + expect(callout).toHaveClass("max-h-0"); + }); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionPage.tsx b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionPage.tsx new file mode 100644 index 000000000..06c2521f2 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionPage.tsx @@ -0,0 +1,495 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + closestCenter, + DndContext, + DragEndEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import FleetPoolRow from "./FleetPoolRow"; +import PoolSelectionModal from "./PoolSelectionModal/PoolSelectionModal"; +import { MiningPool } from "./types"; +import { PoolConfig, PoolSlotSource } from "@/protoFleet/api/useMinerCommand"; +import useMinerPoolAssignments from "@/protoFleet/api/useMinerPoolAssignments"; +import usePools from "@/protoFleet/api/usePools"; +import { Alert, DismissCircleDark, Success } from "@/shared/assets/icons"; +import { iconSizes } from "@/shared/assets/icons/constants"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import Callout, { DismissibleCalloutWrapper, intents } from "@/shared/components/Callout"; +import Header from "@/shared/components/Header"; +import { MAX_POOLS } from "@/shared/components/MiningPools/constants"; +import PageOverlay from "@/shared/components/PageOverlay"; +import ProgressCircular from "@/shared/components/ProgressCircular"; +const UNKNOWN_POOL_ID_PREFIX = "unknown-"; + +interface AssignedPoolData { + poolId: string | undefined; // undefined when pool not in Fleet + poolName: string; // Stored locally to avoid race conditions with miningPools lookup + poolUrl: string; + poolUsername: string; +} + +interface PoolSelectionPageProps { + open?: boolean; + deviceIdentifiers: string[]; + numberOfMiners?: number; // Optional explicit count (for "all" mode with filters) + currentDevice?: string | null; // Optional single device identifier (for single miner edit) + onAssignPools: (poolConfig: PoolConfig) => Promise; + onDismiss: () => void; +} + +const PoolSelectionPage = ({ + open, + deviceIdentifiers, + numberOfMiners: numberOfMinersOverride, + currentDevice, + onAssignPools, + onDismiss: onCancel, +}: PoolSelectionPageProps) => { + const isVisible = open ?? true; + const [assignedPoolData, setAssignedPoolData] = useState([]); + const [showSelectionModal, setShowSelectionModal] = useState(false); + const [editingPoolIndex, setEditingPoolIndex] = useState(null); + const [testingPoolId, setTestingPoolId] = useState(null); + const [showConnectionCallout, setShowConnectionCallout] = useState(false); + const [connectionError, setConnectionError] = useState(false); + + const showSuccessCallout = useMemo( + () => showConnectionCallout && !testingPoolId && !connectionError, + [showConnectionCallout, testingPoolId, connectionError], + ); + + const showErrorCallout = useMemo( + () => showConnectionCallout && !testingPoolId && connectionError, + [showConnectionCallout, testingPoolId, connectionError], + ); + + const { fetchPoolAssignments, isLoading: isLoadingAssignments } = useMinerPoolAssignments(); + const { miningPools, validatePool } = usePools(isVisible); + const [isAssigning, setIsAssigning] = useState(false); + + const loadedDeviceRef = useRef(null); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + // Handle ESC key to dismiss the page (only when modal is not open) + useEffect(() => { + if (!isVisible) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && !showSelectionModal) { + onCancel(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isVisible, onCancel, showSelectionModal]); + + // Reset internal state when hidden to mirror prior conditional-mount behavior. + useEffect(() => { + if (isVisible) { + return; + } + + loadedDeviceRef.current = null; + setAssignedPoolData([]); + setShowSelectionModal(false); + setEditingPoolIndex(null); + setTestingPoolId(null); + setShowConnectionCallout(false); + setConnectionError(false); + }, [isVisible]); + + useEffect(() => { + if (!isVisible) { + return; + } + + const deviceToLoad = currentDevice ?? (deviceIdentifiers.length === 1 ? deviceIdentifiers[0] : null); + + if (loadedDeviceRef.current === deviceToLoad) { + return; + } + + const isDeviceChange = loadedDeviceRef.current !== null; + let isMounted = true; + + const loadExistingPoolAssignments = async () => { + if (isDeviceChange) { + setAssignedPoolData([]); + } + + if (!deviceToLoad) { + loadedDeviceRef.current = deviceToLoad; + return; + } + + const pools = await fetchPoolAssignments(deviceToLoad); + if (!isMounted) return; + + const poolData: AssignedPoolData[] = pools.map((pool) => ({ + poolId: pool.poolId?.toString(), + poolName: "", + poolUrl: pool.url, + poolUsername: pool.username, + })); + setAssignedPoolData(poolData); + loadedDeviceRef.current = deviceToLoad; + }; + + loadExistingPoolAssignments(); + + return () => { + isMounted = false; + }; + }, [isVisible, deviceIdentifiers, currentDevice, fetchPoolAssignments]); + + // Create a stable ID for each pool (either real poolId or synthetic for unknown pools) + const getPoolDisplayId = useCallback((data: AssignedPoolData, index: number): string => { + return data.poolId ?? `${UNKNOWN_POOL_ID_PREFIX}${index}`; + }, []); + + // IDs for drag-and-drop context + const sortableIds = useMemo( + () => assignedPoolData.map((data, index) => getPoolDisplayId(data, index)), + [assignedPoolData, getPoolDisplayId], + ); + + // Map assigned pool data to MiningPool objects for display + const assignedPools = useMemo( + () => + assignedPoolData.map((data, index): MiningPool => { + // Use stored pool name if available (for newly created pools). + // Otherwise look up from miningPools (for pools loaded from API). + let name = data.poolName; + if (!name && data.poolId) { + const knownPool = miningPools.find((p) => p.poolId === data.poolId); + if (knownPool) { + name = knownPool.name; + } + } + + return { + poolId: getPoolDisplayId(data, index), + name: name || data.poolUrl, + poolUrl: data.poolUrl, + username: data.poolUsername, + }; + }), + [assignedPoolData, miningPools, getPoolDisplayId], + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + setAssignedPoolData((items) => { + const oldIndex = sortableIds.indexOf(active.id as string); + const newIndex = sortableIds.indexOf(over.id as string); + return arrayMove(items, oldIndex, newIndex); + }); + } + }, + [sortableIds], + ); + + const handleAddPool = useCallback(() => { + setEditingPoolIndex(null); + setShowSelectionModal(true); + setShowConnectionCallout(false); + }, []); + + const handleUpdatePool = useCallback((index: number) => { + setEditingPoolIndex(index); + setShowSelectionModal(true); + setShowConnectionCallout(false); + }, []); + + const handlePoolSelected = useCallback( + (poolId: string, poolData?: MiningPool) => { + // Use provided poolData (for newly created pools) or find from miningPools + const selectedPool = poolData ?? miningPools.find((p) => p.poolId === poolId); + if (!selectedPool) return; + + const newPoolData: AssignedPoolData = { + poolId: poolId, + poolName: selectedPool.name, + poolUrl: selectedPool.poolUrl, + poolUsername: selectedPool.username, + }; + + if (editingPoolIndex !== null) { + setAssignedPoolData((prev) => { + const newData = [...prev]; + newData[editingPoolIndex] = newPoolData; + return newData; + }); + } else { + setAssignedPoolData((prev) => [...prev, newPoolData]); + } + setShowSelectionModal(false); + setEditingPoolIndex(null); + setShowConnectionCallout(false); + }, + [editingPoolIndex, miningPools], + ); + + const handleRemovePool = useCallback( + (displayId: string) => { + const indexToRemove = sortableIds.indexOf(displayId); + if (indexToRemove !== -1) { + setAssignedPoolData((prev) => prev.filter((_, index) => index !== indexToRemove)); + } + }, + [sortableIds], + ); + + const handleTestConnection = useCallback( + (pool: MiningPool) => { + if (testingPoolId) return; + + setTestingPoolId(pool.poolId); + setConnectionError(false); + validatePool({ + poolInfo: { + url: pool.poolUrl, + username: pool.username, + }, + onSuccess: () => { + setConnectionError(false); + }, + onError: () => { + setConnectionError(true); + }, + onFinally: () => { + setTestingPoolId(null); + setShowConnectionCallout(true); + }, + }); + }, + [testingPoolId, validatePool], + ); + + const handleAssignPoolsClick = async () => { + if (assignedPoolData.length === 0) return; + + setIsAssigning(true); + try { + // Convert assigned pool data to PoolSlotSource objects + const toPoolSlotSource = (data: AssignedPoolData): PoolSlotSource => { + if (data.poolId) { + return { type: "poolId", poolId: data.poolId }; + } else { + return { type: "rawPool", url: data.poolUrl, username: data.poolUsername }; + } + }; + + const poolConfig: PoolConfig = { + defaultPool: toPoolSlotSource(assignedPoolData[0]), + backup1Pool: assignedPoolData[1] ? toPoolSlotSource(assignedPoolData[1]) : undefined, + backup2Pool: assignedPoolData[2] ? toPoolSlotSource(assignedPoolData[2]) : undefined, + }; + + await onAssignPools(poolConfig); + } catch (error) { + console.error("Failed to assign pools:", error); + } finally { + setIsAssigning(false); + } + }; + + const numberOfMiners = numberOfMinersOverride ?? deviceIdentifiers.length; + const buttonText = `Assign to ${numberOfMiners} miner${numberOfMiners === 1 ? "" : "s"}`; + const isSingleMinerEdit = numberOfMiners === 1; + const isLoadingInitialState = isSingleMinerEdit && isLoadingAssignments; + const hasConfiguredPools = assignedPoolData.length > 0; + const canAddMorePools = assignedPoolData.length < MAX_POOLS; + + // Extract known pool IDs for modal exclusion (all assigned pools should be greyed out) + const excludedPoolIds = assignedPoolData.map((data) => data.poolId).filter((id): id is string => id !== undefined); + + // Extract unknown pools (pools on miner but not in Fleet) for display in modal + // Always show all unknown pools, even when editing one (they're disabled anyway) + // Use consistent IDs that match getPoolDisplayId to avoid mismatches + const unknownPoolsForModal = useMemo( + () => + assignedPoolData + .map((data, index) => ({ data, originalIndex: index })) + .filter(({ data }) => data.poolId === undefined) + .map(({ data, originalIndex }) => ({ + poolId: getPoolDisplayId(data, originalIndex), + name: "—", + poolUrl: data.poolUrl, + username: data.poolUsername, + })), + [assignedPoolData, getPoolDisplayId], + ); + + // Check for duplicate URL+username combinations in assigned pools + const hasDuplicatePools = useMemo(() => { + if (assignedPoolData.length < 2) return false; + + const seen = new Set(); + for (const pool of assignedPoolData) { + const key = `${pool.poolUrl.trim().toLowerCase()}|${pool.poolUsername.trim().toLowerCase()}`; + if (seen.has(key)) { + return true; + } + seen.add(key); + } + return false; + }, [assignedPoolData]); + + return ( + +
+
+ } + inline + buttons={[ + { + text: buttonText, + variant: variants.primary, + onClick: handleAssignPoolsClick, + disabled: !hasConfiguredPools || isLoadingInitialState || isAssigning || hasDuplicatePools, + loading: isAssigning, + }, + ]} + /> + +
+
+ {/* Page header */} +
+

Assign pools to your miner

+

+ Add up to 3 pools in order of priority. If a pool fails or is removed, Fleet switches to the next + available pool automatically. +

+
+ + {/* Connection test result callouts */} + } + intent={intents.success} + onDismiss={() => setShowConnectionCallout(false)} + show={showSuccessCallout} + title="Pool connection successful" + testId="pool-selection-page-connection-success-callout" + /> + } + intent={intents.danger} + onDismiss={() => setShowConnectionCallout(false)} + show={showErrorCallout} + title="We couldn't connect with your pool. Review your pool details and try again." + testId="pool-selection-page-connection-error-callout" + /> + + {/* Duplicate pools warning */} + {hasDuplicatePools && ( + } + title="Duplicate pool configuration detected" + subtitle="Two or more pools have the same URL and username. Please remove or change the duplicate pools before assigning." + /> + )} + + {/* Pool list */} + {isLoadingInitialState ? ( +
+ + Loading pool configuration... +
+ ) : !hasConfiguredPools ? ( + // Empty state - just the Add pool button aligned left +
+
+ ) : ( + // Pool list +
+ + +
+ {assignedPools.map((pool, index) => ( + handleUpdatePool(index)} + onTestConnection={() => handleTestConnection(pool)} + onRemove={() => handleRemovePool(pool.poolId)} + testId={`pool-row-${index}`} + /> + ))} +
+
+
+ + {canAddMorePools && ( +
+
+ )} +
+ )} +
+
+
+ + { + setShowSelectionModal(false); + setEditingPoolIndex(null); + }} + onSave={handlePoolSelected} + excludedPoolIds={excludedPoolIds} + unknownPools={unknownPoolsForModal} + /> +
+ ); +}; + +export default PoolSelectionPage; diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionPageWrapper.tsx b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionPageWrapper.tsx new file mode 100644 index 000000000..58c4715a0 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionPageWrapper.tsx @@ -0,0 +1,80 @@ +import { useMemo } from "react"; +import PoolSelectionPage from "./PoolSelectionPage"; +import { PoolConfig, useMinerCommand } from "@/protoFleet/api/useMinerCommand"; +import type { MinerSelection } from "@/protoFleet/features/fleetManagement/components/MinerActionsMenu/useMinerActions"; +import { + createDeviceSelector, + type DeviceFilterCriteria, +} from "@/protoFleet/features/fleetManagement/utils/deviceSelector"; +import { type SelectionMode } from "@/shared/components/List"; + +interface PoolSelectionPageWrapperProps { + open?: boolean; + selectionMode: SelectionMode; + poolNeededCount?: number; // For "all" mode with filter + filterCriteria?: DeviceFilterCriteria; // For "all" mode with filter + selectedMiners?: MinerSelection[]; // For "subset" mode + userUsername?: string; + userPassword?: string; + onSuccess: (batchIdentifier: string) => void; + onError?: (error: string) => void; + onDismiss: () => void; +} + +const PoolSelectionPageWrapper = ({ + open, + selectionMode, + poolNeededCount, + filterCriteria, + selectedMiners, + userUsername, + userPassword, + onSuccess, + onError, + onDismiss: onDismiss, +}: PoolSelectionPageWrapperProps) => { + const { updateMiningPools } = useMinerCommand(); + + const deviceIdentifiers = useMemo( + () => (selectedMiners ? selectedMiners.map((m) => m.deviceIdentifier) : []), + [selectedMiners], + ); + + const deviceSelector = useMemo( + () => + selectionMode === "none" ? undefined : createDeviceSelector(selectionMode, deviceIdentifiers, filterCriteria), + [selectionMode, deviceIdentifiers, filterCriteria], + ); + + const handleAssignPools = async (poolConfig: PoolConfig) => { + if (!deviceSelector) return; + await updateMiningPools({ + deviceSelector, + poolConfig, + userUsername: userUsername || "", + userPassword: userPassword || "", + onSuccess: (response) => { + onSuccess(response.batchIdentifier); + onDismiss(); + }, + onError: (error) => { + console.error("Failed to assign pools:", error); + onError?.("Failed to assign pools"); + onDismiss(); + }, + }); + }; + + return ( + + ); +}; + +export default PoolSelectionPageWrapper; diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolsList/PoolsList.stories.tsx b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolsList/PoolsList.stories.tsx new file mode 100644 index 000000000..fca586e8d --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolsList/PoolsList.stories.tsx @@ -0,0 +1,53 @@ +import type { ReactNode } from "react"; +import PoolsListComponent from "."; +import { MockedPoolApis } from "@/protoFleet/stories/MockedPoolApis"; + +const withMockedPoolApis = (Story: () => ReactNode) => ( + + + +); + +interface PoolsListArgs { + title: string; + subtitle: string; + createNewLabel: string; + poolNumber?: number; +} + +export const PoolsList = ({ title, subtitle, createNewLabel, poolNumber }: PoolsListArgs) => { + return ( + {}} + createNewLabel={createNewLabel} + poolNumber={poolNumber} + /> + ); +}; + +export default { + title: "Proto Fleet/Action Bar/Settings widget/Pools modal/Pools list", + decorators: [withMockedPoolApis], + args: { + title: "Default pool", + subtitle: "", + createNewLabel: "Add pool", + poolNumber: undefined, + }, + argTypes: { + title: { + control: "text", + }, + subtitle: { + control: "text", + }, + createNewLabel: { + control: "text", + }, + poolNumber: { + control: "number", + }, + }, +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolsList/PoolsList.test.tsx b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolsList/PoolsList.test.tsx new file mode 100644 index 000000000..cfb3d648f --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolsList/PoolsList.test.tsx @@ -0,0 +1,135 @@ +import { fireEvent, render } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import PoolsList from "."; +import { PoolSchema } from "@/protoFleet/api/generated/pools/v1/pools_pb"; +import usePools from "@/protoFleet/api/usePools"; + +vi.mock("@/protoFleet/api/usePools"); + +describe("Pools list", () => { + const mockPools = [ + create(PoolSchema, { + poolId: BigInt(1), + poolName: "Client pool A1", + url: "stratum+tcp://mine.ocean.xyz:3323", + username: "user1", + }), + create(PoolSchema, { + poolId: BigInt(2), + poolName: "Client pool A2", + url: "stratum+tcp://mine.ocean.xyz:3324", + username: "user2", + }), + ]; + + const onSelect = vi.fn(); + + beforeEach(() => { + vi.mocked(usePools).mockReturnValue({ + pools: mockPools, + miningPools: mockPools.map((pool) => ({ + poolId: pool.poolId.toString(), + name: pool.poolName, + poolUrl: pool.url, + username: pool.username, + })), + validatePool: vi.fn(), + createPool: vi.fn(), + updatePool: vi.fn(), + deletePool: vi.fn(), + validatePoolPending: false, + isLoading: false, + }); + }); + + const defaultPoolTitle = "Default pool"; + const defaultPoolSubtitle = "Select one default pool"; + const backupPoolTitle = "Backup pool #1"; + const backupPoolSubtitle = "Optional"; + const addDefaultPoolLabel = "Add pool"; + const addBackupPoolLabel = "Add pool"; + + test("renders pool card with default pool", () => { + const { getByText, getByRole } = render( + , + ); + + expect(getByText(defaultPoolTitle)).toBeInTheDocument(); + if (defaultPoolSubtitle) { + expect(getByText(defaultPoolSubtitle)).toBeInTheDocument(); + } + expect(getByText(addDefaultPoolLabel)).toBeInTheDocument(); + expect(getByRole("button", { name: addDefaultPoolLabel })).toBeInTheDocument(); + }); + + test("renders pool card with backup pool and number badge", () => { + const { getByText, getByRole } = render( + , + ); + + expect(getByText(backupPoolTitle)).toBeInTheDocument(); + expect(getByText(backupPoolSubtitle)).toBeInTheDocument(); + expect(getByText(addBackupPoolLabel)).toBeInTheDocument(); + expect(getByRole("button", { name: addBackupPoolLabel })).toBeInTheDocument(); + expect(getByText("1")).toBeInTheDocument(); + }); + + test("opens pool selection modal when Add pool button is clicked", () => { + const { getByRole, getByText } = render( + , + ); + + fireEvent.click(getByRole("button", { name: addDefaultPoolLabel })); + expect(getByText("Select pool")).toBeInTheDocument(); + }); + + test("disables Add pool button when disabled prop is true", () => { + const { getByRole } = render( + , + ); + + const addButton = getByRole("button", { name: addBackupPoolLabel }); + expect(addButton).toBeDisabled(); + }); + + test("sets aria-disabled when disabled", () => { + const { getByTestId } = render( + , + ); + + const poolCard = getByTestId("backup-pool-1"); + expect(poolCard).toHaveAttribute("aria-disabled", "true"); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolsList/PoolsList.tsx b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolsList/PoolsList.tsx new file mode 100644 index 000000000..5439f6261 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolsList/PoolsList.tsx @@ -0,0 +1,173 @@ +import { useState } from "react"; +import PoolSelectionModal from "../PoolSelectionModal/PoolSelectionModal"; +import { MiningPool } from "../types"; +import usePools from "@/protoFleet/api/usePools"; +import MiningPools from "@/shared/assets/icons/MiningPools"; +import Button from "@/shared/components/Button"; +import { sizes, variants } from "@/shared/components/Button"; +import ProgressCircular from "@/shared/components/ProgressCircular"; +import SlotNumber from "@/shared/components/SlotNumber/SlotNumber"; + +type PoolSelectionState = + | { status: "idle"; poolId?: undefined } + | { status: "validating"; poolId: string; pool: MiningPool } + | { status: "valid"; poolId: string; pool: MiningPool } + | { status: "error"; poolId: string; pool: MiningPool; error: string }; + +interface MiningPoolsListProps { + title: string; + subtitle: string; + onSelect: (poolId: string) => void; + createNewLabel: string; + poolNumber?: number; + excludedPoolIds?: (string | undefined)[]; + testId?: string; + disabled?: boolean; + selectedPoolId?: string; +} + +const PoolsList = ({ + title, + subtitle, + onSelect, + createNewLabel, + poolNumber, + excludedPoolIds = [], + testId, + disabled = false, + selectedPoolId, +}: MiningPoolsListProps) => { + const [showSelectionModal, setShowSelectionModal] = useState(false); + const [poolState, setPoolState] = useState({ status: "idle" }); + + const { validatePool, miningPools } = usePools(); + + const findPoolById = (poolId: string): MiningPool | undefined => { + return miningPools.find((p) => p.poolId === poolId); + }; + + // Derive effective state: if parent's selectedPoolId doesn't match our poolState's poolId, treat as idle + const isStateValid = poolState.status !== "idle" && poolState.poolId === selectedPoolId; + + // Get the selected pool - either from our local state (during validation) or from the pools list (for pre-populated selections) + const selectedPool = isStateValid ? poolState.pool : selectedPoolId ? (findPoolById(selectedPoolId) ?? null) : null; + + const isTestingConnection = isStateValid && poolState.status === "validating"; + const poolError = isStateValid && poolState.status === "error" ? poolState.error : null; + + const displayError = poolError; + + const handlePoolSelect = (newPoolId: string, newPool?: MiningPool) => { + // Use newPool if provided (e.g., from pool creation flow) to avoid race condition. + // When a pool is created, setState is async so the pool may not be in miningPools yet. + const pool = newPool ?? findPoolById(newPoolId); + if (!pool) return; + + setPoolState({ status: "validating", poolId: newPoolId, pool }); + setShowSelectionModal(false); + + const minSpinnerDisplayTime = 800; + const startTime = Date.now(); + + const withMinimumDelay = (callback: () => void) => { + const elapsed = Date.now() - startTime; + const remainingTime = Math.max(0, minSpinnerDisplayTime - elapsed); + setTimeout(callback, remainingTime); + }; + + const finishTesting = (error?: string) => { + withMinimumDelay(() => { + if (error) { + console.error(error); + setPoolState({ status: "error", poolId: newPoolId, pool, error: "Connection failed" }); + } else { + setPoolState({ status: "valid", poolId: newPoolId, pool }); + } + onSelect(pool.poolId); + }); + }; + + validatePool({ + poolInfo: { + url: pool.poolUrl, + username: pool.username, + }, + onSuccess: () => finishTesting(), + onError: (error) => finishTesting(error), + }); + }; + + const handleUpdate = () => { + setShowSelectionModal(true); + }; + + return ( + <> +
+ {/* Header */} +
+ {/* Icon */} +
+ {poolNumber !== undefined ? : } +
+ + {/* Title */} +
+

{title}

+
+ {selectedPool ? ( +

+ Configured pool:{" "} + {selectedPool.name || selectedPool.poolUrl} +

+ ) : subtitle ? ( +

{subtitle}

+ ) : null} + {displayError ?

{displayError}

: null} +
+
+
+ + {/* Button or Testing Connection */} +
+ {isTestingConnection ? ( +
+ + Testing connection +
+ ) : selectedPool ? ( +
+
+ + {showSelectionModal ? ( + setShowSelectionModal(false)} + onSave={handlePoolSelect} + excludedPoolIds={excludedPoolIds} + /> + ) : null} + + ); +}; + +export default PoolsList; diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolsList/index.ts b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolsList/index.ts new file mode 100644 index 000000000..08f3de4d8 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolsList/index.ts @@ -0,0 +1,3 @@ +import PoolsList from "./PoolsList"; + +export default PoolsList; diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/constants.ts b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/constants.ts new file mode 100644 index 000000000..37fa82ab2 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/constants.ts @@ -0,0 +1 @@ +export const maxNumberOfBackupPools = 2; diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/index.ts b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/index.ts new file mode 100644 index 000000000..e4efd224e --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/index.ts @@ -0,0 +1,3 @@ +import PoolSelectionPageWrapper from "./PoolSelectionPageWrapper"; + +export default PoolSelectionPageWrapper; diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/types.ts b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/types.ts new file mode 100644 index 000000000..1b9e30190 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/types.ts @@ -0,0 +1,6 @@ +export type MiningPool = { + poolId: string; + name: string; + poolUrl: string; + username: string; +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/index.ts b/client/src/protoFleet/features/fleetManagement/components/ActionBar/index.ts new file mode 100644 index 000000000..890f53a88 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/index.ts @@ -0,0 +1,3 @@ +import ActionBar from "./ActionBar"; + +export default ActionBar; diff --git a/client/src/protoFleet/features/fleetManagement/components/BulkActions/BulkActionConfirmDialog.tsx b/client/src/protoFleet/features/fleetManagement/components/BulkActions/BulkActionConfirmDialog.tsx new file mode 100644 index 000000000..258c21a5f --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/BulkActions/BulkActionConfirmDialog.tsx @@ -0,0 +1,48 @@ +import { ActionWarnDialogOptions } from "./types"; +import { variants } from "@/shared/components/Button"; +import Dialog from "@/shared/components/Dialog"; + +interface BulkActionConfirmDialogProps { + open?: boolean; + actionConfirmation: ActionWarnDialogOptions; + onConfirmation: () => void; + onCancel: () => void; + testId: string; +} + +const BulkActionConfirmDialog = ({ + open, + actionConfirmation, + onConfirmation, + onCancel, + testId, +}: BulkActionConfirmDialogProps) => { + return ( + + ); +}; + +export default BulkActionConfirmDialog; diff --git a/client/src/protoFleet/features/fleetManagement/components/BulkActions/BulkActionsPopover.tsx b/client/src/protoFleet/features/fleetManagement/components/BulkActions/BulkActionsPopover.tsx new file mode 100644 index 000000000..88591028e --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/BulkActions/BulkActionsPopover.tsx @@ -0,0 +1,69 @@ +import { BulkAction } from "./types"; +import Divider from "@/shared/components/Divider"; +import Popover, { popoverSizes } from "@/shared/components/Popover"; +import Row from "@/shared/components/Row"; +import { type Position, positions } from "@/shared/constants"; +import { useWindowDimensions } from "@/shared/hooks/useWindowDimensions"; + +interface BulkActionsPopoverProps { + actions: BulkAction[]; + beforeEach: (requiresConfirmation: boolean) => void; + testId: string; + position?: Position; + className?: string; +} + +interface ActionItemProps { + action: BulkAction; + onAction: (action: BulkAction) => void; +} + +const ActionItem = ({ action, onAction }: ActionItemProps) => { + return ( + <> +
+ onAction(action)} + compact + divider={false} + > + {action.title} + +
+ {action.showGroupDivider && } + + ); +}; + +const BulkActionsPopover = ({ + actions, + beforeEach, + testId, + position = positions["top left"], + className, +}: BulkActionsPopoverProps) => { + const { isPhone, isTablet } = useWindowDimensions(); + const onAction = (action: BulkAction) => { + beforeEach(action.requiresConfirmation); + action.actionHandler(); + }; + return ( + + {actions.map((action) => ( + + ))} + + ); +}; + +export default BulkActionsPopover; diff --git a/client/src/protoFleet/features/fleetManagement/components/BulkActions/BulkActionsWidget.stories.tsx b/client/src/protoFleet/features/fleetManagement/components/BulkActions/BulkActionsWidget.stories.tsx new file mode 100644 index 000000000..0f2cec8ac --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/BulkActions/BulkActionsWidget.stories.tsx @@ -0,0 +1,135 @@ +import { useMemo, useState } from "react"; +import { action } from "storybook/actions"; +import { DeviceAction, deviceActions, PerformanceAction, performanceActions } from "../MinerActionsMenu/constants"; +import { BulkAction } from "./types"; +import { BulkActionsPopover } from "."; +import BulkActionsWidgetComponent from "."; +import { ArrowLeftCompact, Curtail, LEDIndicator, Rectangle } from "@/shared/assets/icons"; +import { iconSizes } from "@/shared/assets/icons/constants"; +import { variants } from "@/shared/components/Button"; +import { PopoverProvider } from "@/shared/components/Popover"; + +interface BulkActionsWidgetArgs { + numberOfActions: number; + numberOfMiners: number; +} + +export const BulkActionsWidget = ({ numberOfActions, numberOfMiners }: BulkActionsWidgetArgs) => { + const [currentAction, setCurrentAction] = useState(null); + + const handleBlinkLEDs = () => { + setCurrentAction(deviceActions.blinkLEDs); + action("Blink LEDs")(); + }; + + const handleFactoryReset = () => { + setCurrentAction(deviceActions.factoryReset); + }; + + const handleCurtail = () => { + setCurrentAction(performanceActions.curtail); + }; + + const handleConfirmation = () => { + if (currentAction === deviceActions.factoryReset) { + action("Factory reset")(); + } else { + action("Curtail")(); + } + setCurrentAction(null); + }; + + const popoverActions = useMemo(() => { + const availableActions = [ + { + action: deviceActions.blinkLEDs, + title: "Blink LEDs", + icon: , + actionHandler: handleBlinkLEDs, + requiresConfirmation: false, + }, + { + action: deviceActions.factoryReset, + title: "Factory reset", + icon: , + actionHandler: handleFactoryReset, + requiresConfirmation: true, + confirmation: { + title: `Reset ${numberOfMiners} miners to factory default?`, + subtitle: + "Resetting this miner will remove all settings and mining pool information. You will not lose any mining rewards.", + confirmAction: { + title: "Reset", + variant: variants.secondaryDanger, + }, + testId: "factory-reset-button", + }, + }, + { + action: performanceActions.curtail, + title: "Curtail", + icon: , + actionHandler: handleCurtail, + requiresConfirmation: true, + confirmation: { + title: `Curtail ${numberOfMiners} miners?`, + subtitle: "These miners will reduce power to 0.1 kW and stop hashing.", + confirmAction: { + title: "Curtail", + variant: variants.primary, + }, + testId: "curtail-button", + }, + }, + ] as BulkAction[]; + return availableActions.slice(0, numberOfActions); + }, [numberOfActions, numberOfMiners]); + + return ( +
+ + + buttonIcon={} + buttonTitle="Bulk actions" + actions={popoverActions} + onConfirmation={handleConfirmation} + onCancel={action("Action cancelled")} + currentAction={currentAction} + renderPopover={(beforeEach) => ( + + actions={popoverActions} + beforeEach={beforeEach} + testId="widget-popover" + /> + )} + testId="widget" + /> + +
+ ); +}; + +export default { + title: "Proto Fleet/Action Bar/Bulk Actions Widget", + parameters: { + docs: { + source: { + // Tell storybook to not infer the code from the rendered component because that would cause infinite loop. + // It is caused by the fact that popover actions are a dynamic array. + type: "code", + }, + }, + }, + args: { + numberOfActions: 1, + numberOfMiners: 1, + }, + argTypes: { + numberOfActions: { + control: { type: "range", min: 1, max: 3, step: 1 }, + }, + numberOfMiners: { + control: { type: "range", min: 1, max: 25, step: 1 }, + }, + }, +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/BulkActions/BulkActionsWidget.test.tsx b/client/src/protoFleet/features/fleetManagement/components/BulkActions/BulkActionsWidget.test.tsx new file mode 100644 index 000000000..1bccefba6 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/BulkActions/BulkActionsWidget.test.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import BulkActionsWidget from "./BulkActionsWidget"; +import { type BulkAction } from "./types"; +import { deviceActions } from "@/protoFleet/features/fleetManagement/components/MinerActionsMenu/constants"; +import Button, { variants } from "@/shared/components/Button"; +import { PopoverProvider } from "@/shared/components/Popover"; + +describe("BulkActionsWidget", () => { + test("shows confirmation dialog when a confirmation-requiring quick action is clicked", () => { + const WidgetHarness = () => { + const [currentAction, setCurrentAction] = useState(null); + + const actions: BulkAction[] = [ + { + action: deviceActions.reboot, + title: "Reboot", + icon: null, + actionHandler: () => setCurrentAction(deviceActions.reboot), + requiresConfirmation: true, + confirmation: { + title: "Reboot miners?", + subtitle: "These miners will reboot.", + confirmAction: { + title: "Reboot", + variant: variants.primary, + }, + testId: "reboot-confirm-button", + }, + }, + ]; + + return ( + + + buttonTitle="More" + actions={actions} + currentAction={currentAction} + onCancel={vi.fn()} + renderQuickActions={(onAction) => ( + + )} + renderPopover={() => null} + testId="actions-menu" + /> + + ); + }; + + render(); + + fireEvent.click(screen.getByTestId("quick-reboot")); + + expect(screen.getByText("Reboot miners?")).toBeInTheDocument(); + expect(screen.getByTestId("reboot-confirm-button")).toBeInTheDocument(); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/BulkActions/BulkActionsWidget.tsx b/client/src/protoFleet/features/fleetManagement/components/BulkActions/BulkActionsWidget.tsx new file mode 100644 index 000000000..e02b3d0d7 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/BulkActions/BulkActionsWidget.tsx @@ -0,0 +1,141 @@ +import { Key, ReactNode, useCallback, useEffect, useState } from "react"; +import { clsx } from "clsx"; +import { BulkAction, UnsupportedMinersInfo } from "./types"; +import UnsupportedMinersModal from "./UnsupportedMinersModal"; +import BulkActionConfirmDialog from "@/protoFleet/features/fleetManagement/components/BulkActions/BulkActionConfirmDialog"; +import { SupportedAction } from "@/protoFleet/features/fleetManagement/components/MinerActionsMenu/constants"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import { usePopover } from "@/shared/components/Popover"; +import { useClickOutside } from "@/shared/hooks/useClickOutside"; + +interface BulkActionsWidgetProps { + buttonIcon?: ReactNode; + buttonIconSuffix?: ReactNode; + buttonTitle: string; + actions: BulkAction[]; + onConfirmation?: () => void; + onCancel: () => void; + currentAction: SupportedAction | null; + renderQuickActions?: (onAction: (action: BulkAction) => void) => ReactNode; + renderPopover: (onAction: (requiresConfirmation: boolean) => void) => ReactNode; + testId: string; + unsupportedMinersInfo?: UnsupportedMinersInfo; + onUnsupportedMinersContinue?: () => void; + onUnsupportedMinersDismiss?: () => void; +} + +const BulkActionsWidget = ({ + buttonIcon, + buttonIconSuffix, + buttonTitle, + actions, + onConfirmation, + onCancel, + currentAction, + renderQuickActions, + renderPopover, + testId, + unsupportedMinersInfo, + onUnsupportedMinersContinue, + onUnsupportedMinersDismiss, +}: BulkActionsWidgetProps) => { + const { triggerRef, setPopoverRenderMode } = usePopover(); + useEffect(() => { + setPopoverRenderMode("inline"); + }, [setPopoverRenderMode]); + + const [isOpen, setIsOpen] = useState(false); + + const onClickOutside = useCallback(() => { + setIsOpen(false); + }, []); + + useClickOutside({ + ref: triggerRef, + onClickOutside, + ignoreSelectors: [".popover-content"], + }); + + const [showWarnDialog, setShowWarnDialog] = useState(false); + + const handleAction = (requiresConfirmation: boolean) => { + setIsOpen(false); + if (requiresConfirmation) setShowWarnDialog(true); + }; + + const handleQuickAction = (action: BulkAction) => { + handleAction(action.requiresConfirmation); + action.actionHandler(); + }; + + const handleConfirmation = () => { + setShowWarnDialog(false); + onConfirmation && onConfirmation(); + }; + + const handleCancel = () => { + setShowWarnDialog(false); + onCancel(); + }; + + // Prevent confirmation dialog flash when continuing from unsupported miners modal + const handleUnsupportedMinersContinue = useCallback(() => { + setShowWarnDialog(false); + onUnsupportedMinersContinue?.(); + }, [onUnsupportedMinersContinue]); + + return ( +
+ {renderQuickActions?.(handleQuickAction)} +
+ ) : undefined + } + testId={testId + "-button"} + onClick={() => setIsOpen((prev) => !prev)} + > + {buttonTitle} + + {isOpen && renderPopover(handleAction)} + + {/* Confirmation dialog - shown when all miners support the action */} + {actions + .filter((action) => action.requiresConfirmation) + .map((action) => { + if (action.confirmation === undefined) return null; + const showDialog = currentAction === action.action && showWarnDialog && !unsupportedMinersInfo?.visible; + return ( + + ); + })} +
+ ); +}; + +export default BulkActionsWidget; diff --git a/client/src/protoFleet/features/fleetManagement/components/BulkActions/UnsupportedMinersModal.stories.tsx b/client/src/protoFleet/features/fleetManagement/components/BulkActions/UnsupportedMinersModal.stories.tsx new file mode 100644 index 000000000..7f4321227 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/BulkActions/UnsupportedMinersModal.stories.tsx @@ -0,0 +1,118 @@ +import { useState } from "react"; +import { create } from "@bufbuild/protobuf"; +import { action } from "storybook/actions"; +import UnsupportedMinersModal from "./UnsupportedMinersModal"; +import { UnsupportedMinerGroupSchema } from "@/protoFleet/api/generated/minercommand/v1/command_pb"; + +export default { + title: "Proto Fleet/Fleet Management/UnsupportedMinersModal", + component: UnsupportedMinersModal, +}; + +const mockGroups = [ + create(UnsupportedMinerGroupSchema, { + firmwareVersion: "1.2.3", + model: "S19 Pro", + count: 5, + }), + create(UnsupportedMinerGroupSchema, { + firmwareVersion: "1.1.0", + model: "S19j Pro", + count: 3, + }), +]; + +export const WithSomeSupported = () => { + const [open, setOpen] = useState(true); + + return ( + <> + {!open && ( +
+ +
+ )} + { + action("onContinue")(); + setOpen(false); + }} + onDismiss={() => { + action("onDismiss")(); + setOpen(false); + }} + /> + + ); +}; + +export const NoneSupported = () => { + const [open, setOpen] = useState(true); + + return ( + <> + {!open && ( +
+ +
+ )} + action("onContinue")()} + onDismiss={() => { + action("onDismiss")(); + setOpen(false); + }} + /> + + ); +}; + +export const SingleMiner = () => { + const [open, setOpen] = useState(true); + + const singleGroup = [ + create(UnsupportedMinerGroupSchema, { + firmwareVersion: "1.0.0", + model: "S19 XP", + count: 1, + }), + ]; + + return ( + <> + {!open && ( +
+ +
+ )} + { + action("onContinue")(); + setOpen(false); + }} + onDismiss={() => { + action("onDismiss")(); + setOpen(false); + }} + /> + + ); +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/BulkActions/UnsupportedMinersModal.test.tsx b/client/src/protoFleet/features/fleetManagement/components/BulkActions/UnsupportedMinersModal.test.tsx new file mode 100644 index 000000000..835696c08 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/BulkActions/UnsupportedMinersModal.test.tsx @@ -0,0 +1,322 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import UnsupportedMinersModal from "./UnsupportedMinersModal"; +import type { UnsupportedMinerGroup } from "@/protoFleet/api/generated/minercommand/v1/command_pb"; + +vi.mock("@/shared/assets/icons", () => ({ + Fleet: vi.fn(() =>
), +})); + +vi.mock("@/shared/components/Button", () => ({ + sizes: { base: "base" }, + variants: { primary: "primary", secondary: "secondary" }, +})); + +vi.mock("@/shared/components/ButtonGroup", () => ({ + groupVariants: { leftAligned: "leftAligned" }, +})); + +vi.mock("@/shared/components/Dialog", () => ({ + default: vi.fn(({ open, title, subtitle, buttons, testId }) => + open ? ( +
+
{title}
+
{subtitle}
+ {buttons?.map((b: { text: string; onClick: () => void; testId?: string }, i: number) => ( + + ))} +
+ ) : null, + ), +})); + +vi.mock("@/shared/components/Divider", () => ({ + default: vi.fn(() =>
), +})); + +vi.mock("@/shared/components/Modal", () => ({ + default: vi.fn(({ open, buttons, children }) => + open ? ( +
+
{children}
+ {buttons?.map((b: { text: string; onClick: () => void; testId?: string }, i: number) => ( + + ))} +
+ ) : null, + ), +})); + +vi.mock("@/shared/components/Row", () => ({ + default: vi.fn(({ children }) =>
{children}
), +})); + +const makeGroup = ( + overrides: Partial<{ firmwareVersion: string; model: string; count: number }>, +): UnsupportedMinerGroup => + ({ + firmwareVersion: "v20240702", + model: "Antminer S21", + count: 4, + ...overrides, + }) as unknown as UnsupportedMinerGroup; + +describe("UnsupportedMinersModal", () => { + const mockOnContinue = vi.fn(); + const mockOnDismiss = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("noneSupported=true — Dialog", () => { + it("renders Dialog when open is true", () => { + render( + , + ); + expect(screen.getByTestId("action-not-supported-dialog")).toBeInTheDocument(); + expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); + }); + + it("does not render when open is false", () => { + render( + , + ); + expect(screen.queryByTestId("action-not-supported-dialog")).not.toBeInTheDocument(); + }); + + it("shows 'Action not supported' title", () => { + render( + , + ); + expect(screen.getByTestId("dialog-title")).toHaveTextContent("Action not supported"); + }); + + it("uses plural 'miners'' in subtitle when count > 1", () => { + render( + , + ); + expect(screen.getByTestId("dialog-subtitle")).toHaveTextContent("miners'"); + }); + + it("uses singular 'miner's' in subtitle when count is 1", () => { + render( + , + ); + expect(screen.getByTestId("dialog-subtitle")).toHaveTextContent("miner's"); + }); + + it("shows Dismiss button and calls onDismiss when clicked", () => { + render( + , + ); + fireEvent.click(screen.getByTestId("dismiss-button")); + expect(mockOnDismiss).toHaveBeenCalledOnce(); + expect(mockOnContinue).not.toHaveBeenCalled(); + }); + }); + + describe("noneSupported=false — Modal with rows", () => { + it("renders Modal when open is true", () => { + render( + , + ); + expect(screen.getByTestId("modal")).toBeInTheDocument(); + expect(screen.queryByTestId("action-not-supported-dialog")).not.toBeInTheDocument(); + }); + + it("does not render when open is false", () => { + render( + , + ); + expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); + }); + + it("shows correct title and description in body", () => { + render( + , + ); + expect(screen.getByText("Some miners do not support this action.")).toBeInTheDocument(); + expect(screen.getByText("This action will be skipped for 12 miners.")).toBeInTheDocument(); + }); + + it("shows Continue button and calls onContinue when clicked", () => { + render( + , + ); + fireEvent.click(screen.getByTestId("continue-button")); + expect(mockOnContinue).toHaveBeenCalledOnce(); + expect(mockOnDismiss).not.toHaveBeenCalled(); + }); + + it("renders a row for each unsupported group", () => { + const groups = [ + makeGroup({ firmwareVersion: "v20240702", model: "Antminer S21" }), + makeGroup({ firmwareVersion: "v20240703", model: "Antminer S19 XP" }), + makeGroup({ firmwareVersion: "v20240704", model: "Antminer S19 Pro" }), + ]; + render( + , + ); + expect(screen.getAllByTestId("row")).toHaveLength(3); + }); + + it("displays firmware version and model for each group", () => { + render( + , + ); + expect(screen.getByText("Firmware v20240702")).toBeInTheDocument(); + expect(screen.getByText("Antminer S21")).toBeInTheDocument(); + }); + + it("shows plural 'miners' for count greater than 1", () => { + render( + , + ); + expect(screen.getByText("5 miners")).toBeInTheDocument(); + }); + + it("shows singular 'miner' for count of 1", () => { + render( + , + ); + expect(screen.getByText("1 miner")).toBeInTheDocument(); + }); + + it("renders dividers between groups but not after the last one", () => { + const groups = [ + makeGroup({ firmwareVersion: "v20240702", model: "Antminer S21" }), + makeGroup({ firmwareVersion: "v20240703", model: "Antminer S19 XP" }), + makeGroup({ firmwareVersion: "v20240704", model: "Antminer S19 Pro" }), + ]; + render( + , + ); + // 3 groups → 2 dividers (between groups, not after last) + expect(screen.getAllByTestId("divider")).toHaveLength(2); + }); + + it("renders no dividers for a single group", () => { + render( + , + ); + expect(screen.queryByTestId("divider")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/BulkActions/UnsupportedMinersModal.tsx b/client/src/protoFleet/features/fleetManagement/components/BulkActions/UnsupportedMinersModal.tsx new file mode 100644 index 000000000..0c2aa62c3 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/BulkActions/UnsupportedMinersModal.tsx @@ -0,0 +1,92 @@ +import { Fragment } from "react"; +import { UnsupportedMinerGroup } from "@/protoFleet/api/generated/minercommand/v1/command_pb"; +import { Fleet } from "@/shared/assets/icons"; +import { iconSizes } from "@/shared/assets/icons/constants"; +import { variants } from "@/shared/components/Button"; +import { groupVariants } from "@/shared/components/ButtonGroup"; +import Dialog from "@/shared/components/Dialog"; +import Divider from "@/shared/components/Divider"; +import Modal from "@/shared/components/Modal"; +import Row from "@/shared/components/Row"; + +interface UnsupportedMinersModalProps { + open?: boolean; + unsupportedGroups: UnsupportedMinerGroup[]; + totalUnsupportedCount: number; + noneSupported: boolean; + onContinue: () => void; + onDismiss: () => void; +} + +const UnsupportedMinersModal = ({ + open, + unsupportedGroups, + totalUnsupportedCount, + noneSupported, + onContinue, + onDismiss, +}: UnsupportedMinersModalProps) => { + const minerText = totalUnsupportedCount === 1 ? "miner's" : "miners'"; + + return ( + <> + + +
+

Some miners do not support this action.

+

+ This action will be skipped for {totalUnsupportedCount} miners. +

+
+ {unsupportedGroups.map((group, index) => ( + + +
+ +
+
Firmware {group.firmwareVersion}
+
{group.model}
+
+
+
+ {group.count} {group.count === 1 ? "miner" : "miners"} +
+
+ {index < unsupportedGroups.length - 1 && } +
+ ))} +
+ + ); +}; + +export default UnsupportedMinersModal; diff --git a/client/src/protoFleet/features/fleetManagement/components/BulkActions/index.ts b/client/src/protoFleet/features/fleetManagement/components/BulkActions/index.ts new file mode 100644 index 000000000..7111e9c3e --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/BulkActions/index.ts @@ -0,0 +1,5 @@ +import BulkActionsPopover from "./BulkActionsPopover"; +import BulkActionsWidget from "./BulkActionsWidget"; + +export { BulkActionsPopover }; +export default BulkActionsWidget; diff --git a/client/src/protoFleet/features/fleetManagement/components/BulkActions/types.ts b/client/src/protoFleet/features/fleetManagement/components/BulkActions/types.ts new file mode 100644 index 000000000..f853578d5 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/BulkActions/types.ts @@ -0,0 +1,32 @@ +import { ReactNode } from "react"; +import { UnsupportedMinerGroup } from "@/protoFleet/api/generated/minercommand/v1/command_pb"; +import { type ButtonVariant } from "@/shared/components/Button"; + +export type BulkAction = { + action: ActionType; + title: string; + icon: ReactNode; + actionHandler: () => void; + requiresConfirmation: boolean; + confirmation?: ActionWarnDialogOptions; + /** Shows a thicker divider after this action to separate groups */ + showGroupDivider?: boolean; +}; + +export type ActionWarnDialogOptions = { + title: string; + subtitle: string; + confirmAction: { + title: string; + variant: ButtonVariant; + }; + testId: string; +}; + +export type UnsupportedMinersInfo = { + visible: boolean; + unsupportedGroups: UnsupportedMinerGroup[]; + totalUnsupportedCount: number; + noneSupported: boolean; + supportedDeviceIdentifiers: string[]; +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/Fleet/Fleet.test.tsx b/client/src/protoFleet/features/fleetManagement/components/Fleet/Fleet.test.tsx new file mode 100644 index 000000000..651524eec --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/Fleet/Fleet.test.tsx @@ -0,0 +1,298 @@ +import { MemoryRouter } from "react-router-dom"; +import { render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { POLL_INTERVAL_MS } from "./constants"; +import Fleet from "./Fleet"; + +const { mockMinerList } = vi.hoisted(() => ({ + mockMinerList: vi.fn(() =>
MinerList
), +})); + +// Mock all dependencies +vi.mock("@/protoFleet/api/useFleet", () => ({ + default: vi.fn(() => ({ + minerIds: [], + totalMiners: 0, + availableModels: [], + currentPage: 0, + hasPreviousPage: false, + isInitialLoad: false, + hasMore: false, + hasInitialLoadCompleted: false, + isLoading: false, + loadMore: vi.fn(), + goToNextPage: vi.fn(), + goToPrevPage: vi.fn(), + refetch: vi.fn(), + refreshCurrentPage: vi.fn(), + updateMinerWorkerName: vi.fn(), + })), +})); + +vi.mock("@/protoFleet/store", () => ({ + useAuthErrors: vi.fn(() => ({ handleAuthErrors: vi.fn() })), + useTemperatureUnit: vi.fn(() => "C"), + useBatchStateVersion: vi.fn(() => 0), + useStartBatchOperation: vi.fn(() => vi.fn()), + useCompleteBatchOperation: vi.fn(() => vi.fn()), + useRemoveDevicesFromBatch: vi.fn(() => vi.fn()), + useCleanupStaleBatches: vi.fn(() => vi.fn()), + getActiveBatches: vi.fn(() => []), + getAllBatches: vi.fn(() => []), +})); + +vi.mock("@/protoFleet/api/useDeviceSets", () => ({ + useDeviceSets: vi.fn(() => ({ + listGroups: vi.fn(), + listRacks: vi.fn(), + })), +})); + +vi.mock("@/protoFleet/api/useAuthNeededMiners", () => ({ + default: vi.fn(() => ({ totalMiners: 0 })), +})); + +vi.mock("@/protoFleet/api/useDeviceErrors", () => ({ + useDeviceErrors: vi.fn(() => ({ refetch: vi.fn() })), +})); + +vi.mock("@/protoFleet/features/fleetManagement/components/MinerList", () => ({ + default: mockMinerList, +})); + +vi.mock("@/protoFleet/features/onboarding/components/CompleteSetup/CompleteSetup", () => ({ + default: () =>
CompleteSetup
, +})); + +vi.mock("@/protoFleet/features/onboarding/components/Miners", () => ({ + default: () =>
Miners
, +})); + +const createFleetMock = (overrides: Record = {}) => ({ + minerIds: [] as string[], + miners: {}, + totalMiners: 0, + hasMore: false, + hasInitialLoadCompleted: false, + isLoading: false, + refetch: vi.fn() as () => void, + refreshCurrentPage: vi.fn() as () => void, + loadMore: vi.fn() as () => void, + availableModels: [] as string[], + currentPage: 0, + hasPreviousPage: false, + goToNextPage: vi.fn() as () => void, + goToPrevPage: vi.fn() as () => void, + updateMinerWorkerName: vi.fn() as (deviceIdentifier: string, workerName: string) => void, + ...overrides, +}); + +// Helper to render Fleet with Router context +const renderFleet = () => { + return render( + + + , + ); +}; + +describe("Fleet - Polling", () => { + let mockRefreshCurrentPage: ReturnType; + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + vi.useFakeTimers(); + + mockRefreshCurrentPage = vi.fn(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should setup polling interval after initial load completes", async () => { + const useFleetModule = await import("@/protoFleet/api/useFleet"); + + vi.mocked(useFleetModule.default).mockReturnValue( + createFleetMock({ + minerIds: ["miner1"], + totalMiners: 1, + hasInitialLoadCompleted: true, + refreshCurrentPage: mockRefreshCurrentPage as () => void, + currentPage: 1, + }), + ); + + renderFleet(); + + // Advance time by poll interval + vi.advanceTimersByTime(POLL_INTERVAL_MS); + + expect(mockRefreshCurrentPage).toHaveBeenCalled(); + }); + + it("should not poll before initial load completes", async () => { + const useFleetModule = await import("@/protoFleet/api/useFleet"); + + vi.mocked(useFleetModule.default).mockReturnValue( + createFleetMock({ + refreshCurrentPage: mockRefreshCurrentPage as () => void, + currentPage: 1, + }), + ); + + renderFleet(); + + // Advance time by poll interval + vi.advanceTimersByTime(POLL_INTERVAL_MS); + + expect(mockRefreshCurrentPage).not.toHaveBeenCalled(); + }); + + it("should poll repeatedly at the configured interval", async () => { + const useFleetModule = await import("@/protoFleet/api/useFleet"); + + vi.mocked(useFleetModule.default).mockReturnValue( + createFleetMock({ + minerIds: ["miner1"], + totalMiners: 1, + hasInitialLoadCompleted: true, + refreshCurrentPage: mockRefreshCurrentPage as () => void, + currentPage: 1, + }), + ); + + renderFleet(); + + // First poll + vi.advanceTimersByTime(POLL_INTERVAL_MS); + const callsAfterFirst = mockRefreshCurrentPage.mock.calls.length; + expect(callsAfterFirst).toBeGreaterThan(0); + + // Second poll + vi.advanceTimersByTime(POLL_INTERVAL_MS); + expect(mockRefreshCurrentPage.mock.calls.length).toBeGreaterThan(callsAfterFirst); + }); + + it("should cleanup polling interval on unmount", async () => { + const useFleetModule = await import("@/protoFleet/api/useFleet"); + + vi.mocked(useFleetModule.default).mockReturnValue( + createFleetMock({ + minerIds: ["miner1"], + totalMiners: 1, + hasInitialLoadCompleted: true, + refreshCurrentPage: mockRefreshCurrentPage as () => void, + currentPage: 1, + }), + ); + + const { unmount } = renderFleet(); + + vi.advanceTimersByTime(POLL_INTERVAL_MS); + const callsBeforeUnmount = mockRefreshCurrentPage.mock.calls.length; + expect(callsBeforeUnmount).toBeGreaterThan(0); + + unmount(); + + // Advance time again - should not poll after unmount + vi.advanceTimersByTime(POLL_INTERVAL_MS); + expect(mockRefreshCurrentPage.mock.calls.length).toBe(callsBeforeUnmount); + }); +}); + +describe("Fleet - Component Integration", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockMinerList.mockClear(); + }); + + it("should render MinerList component", () => { + const { getByTestId } = renderFleet(); + expect(getByTestId("miner-list")).toBeInTheDocument(); + }); + + it("should render CompleteSetup component when miners exist", async () => { + const useFleetModule = await import("@/protoFleet/api/useFleet"); + + vi.mocked(useFleetModule.default).mockReturnValue( + createFleetMock({ + minerIds: ["miner1"], + totalMiners: 1, + hasInitialLoadCompleted: true, + }), + ); + + const { getByTestId } = renderFleet(); + expect(getByTestId("complete-setup")).toBeInTheDocument(); + }); + + it("should not render CompleteSetup when there are no miners", async () => { + const useFleetModule = await import("@/protoFleet/api/useFleet"); + + vi.mocked(useFleetModule.default).mockReturnValue(createFleetMock({ hasInitialLoadCompleted: true })); + + const { queryByTestId } = renderFleet(); + expect(queryByTestId("complete-setup")).not.toBeInTheDocument(); + }); + + it("should render CompleteSetup when filters yield 0 results but miners exist", async () => { + const useFleetModule = await import("@/protoFleet/api/useFleet"); + + vi.mocked(useFleetModule.default).mockImplementation((options: any) => { + if (options.pageSize === 1) { + return createFleetMock({ totalMiners: 5, hasInitialLoadCompleted: true }); + } + return createFleetMock({ totalMiners: 0, hasInitialLoadCompleted: true }); + }); + + const { getByTestId } = renderFleet(); + expect(getByTestId("complete-setup")).toBeInTheDocument(); + }); + + it("should render CompleteSetup when unfiltered count fails but main fleet shows miners", async () => { + const useFleetModule = await import("@/protoFleet/api/useFleet"); + + vi.mocked(useFleetModule.default).mockImplementation((options: any) => { + if (options.pageSize === 1) { + // Unfiltered count fetch failed: hasInitialLoadCompleted is true (set in finally) + // but totalMiners stayed at 0 (never updated on error) + return createFleetMock({ totalMiners: 0, hasInitialLoadCompleted: true }); + } + return createFleetMock({ minerIds: ["m1"], totalMiners: 1, hasInitialLoadCompleted: true }); + }); + + const { getByTestId } = renderFleet(); + expect(getByTestId("complete-setup")).toBeInTheDocument(); + }); + + it("should call useFleet hook with correct parameters", async () => { + const useFleetModule = await import("@/protoFleet/api/useFleet"); + const useFleet = useFleetModule.default; + + renderFleet(); + + expect(useFleet).toHaveBeenCalledWith( + expect.objectContaining({ + pageSize: 50, + }), + ); + }); + + it("shows the loading state during sort refetches even when miners are already present", async () => { + const useFleetModule = await import("@/protoFleet/api/useFleet"); + + vi.mocked(useFleetModule.default).mockReturnValue( + createFleetMock({ + minerIds: ["miner-1"], + totalMiners: 1, + isLoading: true, + }), + ); + + renderFleet(); + + expect(mockMinerList).toHaveBeenCalledWith(expect.objectContaining({ loading: true }), undefined); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/Fleet/Fleet.tsx b/client/src/protoFleet/features/fleetManagement/components/Fleet/Fleet.tsx new file mode 100644 index 000000000..86c54ac79 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/Fleet/Fleet.tsx @@ -0,0 +1,265 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { create } from "@bufbuild/protobuf"; +import { POLL_INTERVAL_MS } from "./constants"; +import { + type SortConfig, + SortConfigSchema, + SortDirection, + SortField, +} from "@/protoFleet/api/generated/common/v1/sort_pb"; +import type { DeviceSet } from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; +import useAuthNeededMiners from "@/protoFleet/api/useAuthNeededMiners"; +import { useDeviceErrors } from "@/protoFleet/api/useDeviceErrors"; +import { useDeviceSets } from "@/protoFleet/api/useDeviceSets"; +import useExportMinerListCsv from "@/protoFleet/api/useExportMinerListCsv"; +import useFleet from "@/protoFleet/api/useFleet"; +import MinerList from "@/protoFleet/features/fleetManagement/components/MinerList"; +import { type MinerColumn } from "@/protoFleet/features/fleetManagement/components/MinerList/constants"; +import { MINERS_PAGE_SIZE } from "@/protoFleet/features/fleetManagement/components/MinerList/constants"; +import { + getColumnForSortField, + getSortField, +} from "@/protoFleet/features/fleetManagement/components/MinerList/sortConfig"; +import { useBatchOperations } from "@/protoFleet/features/fleetManagement/hooks/useBatchOperations"; +import { hasReachedExpectedStatus } from "@/protoFleet/features/fleetManagement/utils/batchStatusCheck"; +import { parseFilterFromURL } from "@/protoFleet/features/fleetManagement/utils/filterUrlParams"; +import { FLEET_VISIBLE_PAIRING_STATUSES } from "@/protoFleet/features/fleetManagement/utils/fleetVisiblePairingFilter"; +import { encodeSortToURL, parseSortFromURL } from "@/protoFleet/features/fleetManagement/utils/sortUrlParams"; +import CompleteSetup from "@/protoFleet/features/onboarding/components/CompleteSetup/CompleteSetup"; +import Miners from "@/protoFleet/features/onboarding/components/Miners"; +import ErrorBoundary from "@/shared/components/ErrorBoundary"; +import { SORT_ASC, SORT_DESC } from "@/shared/components/List/types"; + +// Default sort: Name ascending (alphabetical A-Z) +const DEFAULT_SORT_CONFIG: SortConfig = create(SortConfigSchema, { + field: SortField.NAME, + direction: SortDirection.ASC, +}); + +const Fleet = () => { + const navigate = useNavigate(); + const { listGroups, listRacks } = useDeviceSets(); + const [availableGroups, setAvailableGroups] = useState([]); + const [availableRacks, setAvailableRacks] = useState([]); + + useEffect(() => { + listGroups({ + onSuccess: (deviceSets) => { + setAvailableGroups(deviceSets); + }, + }); + listRacks({ + onSuccess: (deviceSets) => { + setAvailableRacks(deviceSets); + }, + }); + }, [listGroups, listRacks]); + + // Get filter and sort from URL - memoize to avoid recreating on every render + const [searchParams] = useSearchParams(); + const currentFilter = useMemo(() => parseFilterFromURL(searchParams), [searchParams]); + const currentSortConfig = useMemo(() => parseSortFromURL(searchParams) ?? DEFAULT_SORT_CONFIG, [searchParams]); + + // Convert proto SortField to MinerColumn for UI component + const currentSort = useMemo(() => { + if (!currentSortConfig) return undefined; + const column = getColumnForSortField(currentSortConfig.field); + if (!column) return undefined; + return { + field: column, + direction: currentSortConfig.direction === SortDirection.ASC ? SORT_ASC : SORT_DESC, + } as const; + }, [currentSortConfig]); + + // Get count of miners requiring authentication (disabled rows) + const { totalMiners: totalAuthNeededMiners } = useAuthNeededMiners({ pageSize: 1, filter: currentFilter }); + const { exportCsv, isExportingCsv } = useExportMinerListCsv({ + filter: currentFilter, + }); + + // Fetch unfiltered total count for the "X of Y miners" header display + // and to guard CompleteSetup rendering (hide when no miners are paired) + const { + totalMiners: totalUnfilteredMiners, + refreshCurrentPage: refreshUnfilteredCount, + hasInitialLoadCompleted: unfilteredCountLoaded, + } = useFleet({ + pageSize: 1, + pairingStatuses: FLEET_VISIBLE_PAIRING_STATUSES, + }); + + // Fetch all devices (both paired and unpaired) with a single API call + const { + minerIds, + miners, + totalMiners, + hasMore, + hasInitialLoadCompleted, + refetch, + refreshCurrentPage, + updateMinerWorkerName, + availableModels, + currentPage, + hasPreviousPage, + goToNextPage, + goToPrevPage, + } = useFleet({ + pageSize: MINERS_PAGE_SIZE, + filter: currentFilter, + sort: currentSortConfig, + pairingStatuses: FLEET_VISIBLE_PAIRING_STATUSES, + }); + + // Fetch errors for all loaded miners + const { errorsByDevice, hasLoaded: errorsLoaded, refetch: refetchErrors } = useDeviceErrors(minerIds); + + // Batch operations (ephemeral UI state) + const { + completeBatchOperation, + removeDevicesFromBatch, + cleanupStaleBatches, + getAllBatches, + getActiveBatches, + batchStateVersion, + } = useBatchOperations(); + + // Poll for miner and error updates to keep data fresh on the current page. + // Both are needed: minerIds is stabilized (same-content → same reference), + // so the useDeviceErrors effect won't re-fire from polling alone. + useEffect(() => { + if (!hasInitialLoadCompleted) return; + const intervalId = setInterval(() => { + refreshCurrentPage(); + refetchErrors(); + }, POLL_INTERVAL_MS); + return () => clearInterval(intervalId); + }, [hasInitialLoadCompleted, refreshCurrentPage, refetchErrors]); + + // Cleanup stale batch operations at the same interval as polling + useEffect(() => { + const interval = setInterval(() => { + cleanupStaleBatches(); + }, POLL_INTERVAL_MS); + return () => clearInterval(interval); + }, [cleanupStaleBatches]); + + // Remove devices from batches once they've reached expected status. + // Only checks visible devices (useFleet keeps one page in memory). + // Off-page devices stay in the batch until they become visible or stale cleanup runs. + useEffect(() => { + for (const batch of getAllBatches()) { + const transitionedIds = batch.deviceIdentifiers.filter((id) => { + const miner = miners[id]; + return miner && hasReachedExpectedStatus(batch.action, miner.deviceStatus, batch.startedAt); + }); + if (transitionedIds.length === 0) continue; + + if (transitionedIds.length === batch.deviceIdentifiers.length) { + // All devices transitioned — complete the entire batch + completeBatchOperation(batch.batchIdentifier); + } else { + // Only some devices transitioned — remove them, keep batch for the rest + removeDevicesFromBatch(batch.batchIdentifier, transitionedIds); + } + } + }, [miners, batchStateVersion, getAllBatches, completeBatchOperation, removeDevicesFromBatch]); + + // Pairing coordination (local state, replaces fleet slice) + const [lastPairingCompletedAt, setLastPairingCompletedAt] = useState(0); + const notifyPairingCompleted = useCallback(() => setLastPairingCompletedAt(Date.now()), []); + + const refetchAll = useCallback(() => { + refetch(); + refreshUnfilteredCount(); + }, [refetch, refreshUnfilteredCount]); + + const [showAddMinersModal, setShowAddMinersModal] = useState(false); + + const handleAddMinersClose = () => { + refetchAll(); + notifyPairingCompleted(); + setShowAddMinersModal(false); + }; + + const handleSort = useCallback( + (column: MinerColumn, direction: "asc" | "desc") => { + const sortField = getSortField(column); + if (!sortField) return; + + const sortDirection = direction === SORT_ASC ? SortDirection.ASC : SortDirection.DESC; + const newSortConfig = create(SortConfigSchema, { field: sortField, direction: sortDirection }); + + // Update URL with new sort params (preserves existing filter params) + const params = new URLSearchParams(searchParams); + encodeSortToURL(params, newSortConfig); + navigate(`?${params.toString()}`, { replace: true }); + }, + [searchParams, navigate], + ); + + return ( + <> + {(!unfilteredCountLoaded || totalUnfilteredMiners > 0 || totalMiners > 0) && ( + + )} + + setShowAddMinersModal(true)} + loading={!hasInitialLoadCompleted} + pageSize={MINERS_PAGE_SIZE} + currentPage={currentPage} + hasPreviousPage={hasPreviousPage} + hasNextPage={hasMore} + onNextPage={goToNextPage} + onPrevPage={goToPrevPage} + currentSort={currentSort} + onSort={handleSort} + availableModels={availableModels} + availableGroups={availableGroups} + availableRacks={availableRacks} + currentFilter={currentFilter} + currentSortConfig={currentSortConfig} + onExportCsv={exportCsv} + exportCsvLoading={isExportingCsv} + onRefetchMiners={refetchAll} + onWorkerNameUpdated={updateMinerWorkerName} + onPairingCompleted={notifyPairingCompleted} + /> + + + {showAddMinersModal && ( + + )} + + ); +}; + +export default Fleet; diff --git a/client/src/protoFleet/features/fleetManagement/components/Fleet/constants.ts b/client/src/protoFleet/features/fleetManagement/components/Fleet/constants.ts new file mode 100644 index 000000000..77b0a694d --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/Fleet/constants.ts @@ -0,0 +1 @@ +export { POLL_INTERVAL_MS } from "@/protoFleet/constants/polling"; diff --git a/client/src/protoFleet/features/fleetManagement/components/Fleet/index.ts b/client/src/protoFleet/features/fleetManagement/components/Fleet/index.ts new file mode 100644 index 000000000..9d968efad --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/Fleet/index.ts @@ -0,0 +1,3 @@ +import Fleet from "./Fleet"; + +export default Fleet; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/AddToGroupModal.stories.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/AddToGroupModal.stories.tsx new file mode 100644 index 000000000..326bc32e4 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/AddToGroupModal.stories.tsx @@ -0,0 +1,86 @@ +import { useState } from "react"; +import { action } from "storybook/actions"; +import AddToGroupModal from "./AddToGroupModal"; + +export default { + title: "Proto Fleet/Fleet Management/AddToGroupModal", + component: AddToGroupModal, +}; + +export const Default = () => { + const [open, setOpen] = useState(true); + + return ( + <> + {!open && ( +
+ +
+ )} + { + action("onDismiss")(); + setOpen(false); + }} + /> + + ); +}; + +export const SingleMiner = () => { + const [open, setOpen] = useState(true); + + return ( + <> + {!open && ( +
+ +
+ )} + { + action("onDismiss")(); + setOpen(false); + }} + /> + + ); +}; + +export const AllMinersSelected = () => { + const [open, setOpen] = useState(true); + + return ( + <> + {!open && ( +
+ +
+ )} + { + action("onDismiss")(); + setOpen(false); + }} + /> + + ); +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/AddToGroupModal.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/AddToGroupModal.tsx new file mode 100644 index 000000000..054f58fe9 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/AddToGroupModal.tsx @@ -0,0 +1,211 @@ +import { ChangeEvent, useCallback, useEffect, useState } from "react"; + +import { type DeviceSet } from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { useDeviceSets } from "@/protoFleet/api/useDeviceSets"; +import Checkbox from "@/shared/components/Checkbox"; +import Input from "@/shared/components/Input"; +import { type SelectionMode } from "@/shared/components/List"; +import Modal from "@/shared/components/Modal"; +import ProgressCircular from "@/shared/components/ProgressCircular"; +import { pushToast, STATUSES as TOAST_STATUSES } from "@/shared/features/toaster"; + +interface AddToGroupModalProps { + open?: boolean; + onDismiss: () => void; + selectedMiners: string[]; + selectionMode: SelectionMode; + displayCount: number; +} + +const pluralizeMiners = (count: number) => `${count} ${count === 1 ? "miner" : "miners"}`; + +const AddToGroupModal = ({ open, onDismiss, selectedMiners, selectionMode, displayCount }: AddToGroupModalProps) => { + const { createGroup, addDevicesToDeviceSet, listGroups } = useDeviceSets(); + + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [newGroupName, setNewGroupName] = useState(""); + const [selectedGroupIds, setSelectedGroupIds] = useState>(new Set()); + const [createNewChecked, setCreateNewChecked] = useState(false); + + useEffect(() => { + if (!open) return; + + setLoading(true); + setGroups([]); + setNewGroupName(""); + setSelectedGroupIds(new Set()); + setCreateNewChecked(false); + + listGroups({ + onSuccess: (deviceSets) => setGroups(deviceSets), + onError: (message) => pushToast({ status: TOAST_STATUSES.error, message }), + onFinally: () => setLoading(false), + }); + }, [open, listGroups]); + + const allDevices = selectionMode === "all"; + const deviceIdentifiers = allDevices ? undefined : selectedMiners; + const minerCount = allDevices ? displayCount : selectedMiners.length; + const hasGroups = groups.length > 0; + + const canSave = hasGroups + ? selectedGroupIds.size > 0 || (createNewChecked && newGroupName.trim().length > 0) + : newGroupName.trim().length > 0; + + const handleToggleGroup = useCallback((id: bigint) => { + setSelectedGroupIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + const handleCreateNewToggle = useCallback((e: ChangeEvent) => { + setCreateNewChecked(e.target.checked); + if (!e.target.checked) { + setNewGroupName(""); + } + }, []); + + const handleSave = useCallback(async () => { + if (!canSave) return; + setSaving(true); + + const promises: Promise[] = []; + + for (const groupId of selectedGroupIds) { + promises.push( + new Promise((resolve, reject) => { + addDevicesToDeviceSet({ + deviceSetId: groupId, + deviceIdentifiers, + allDevices, + onSuccess: () => resolve(), + onError: (msg) => reject(new Error(msg)), + }); + }), + ); + } + + const shouldCreateNew = hasGroups + ? createNewChecked && newGroupName.trim().length > 0 + : newGroupName.trim().length > 0; + + if (shouldCreateNew) { + promises.push( + new Promise((resolve, reject) => { + createGroup({ + label: newGroupName.trim(), + deviceIdentifiers, + allDevices, + onSuccess: () => resolve(), + onError: (msg) => reject(new Error(msg)), + }); + }), + ); + } + + try { + await Promise.all(promises); + pushToast({ + status: TOAST_STATUSES.success, + message: `Added ${pluralizeMiners(minerCount)} to group`, + }); + onDismiss(); + } catch (err) { + pushToast({ status: TOAST_STATUSES.error, message: getErrorMessage(err, "Failed to add to group") }); + } finally { + setSaving(false); + } + }, [ + canSave, + selectedGroupIds, + hasGroups, + createNewChecked, + newGroupName, + addDevicesToDeviceSet, + createGroup, + deviceIdentifiers, + allDevices, + minerCount, + onDismiss, + ]); + + if (!open) return null; + + const title = hasGroups ? "Add to group" : "Add group"; + const description = hasGroups + ? `${pluralizeMiners(minerCount)} will be added to selected groups.` + : `${pluralizeMiners(minerCount)} will be added to the group.`; + + return ( + + {loading ? ( +
+ +
+ ) : hasGroups ? ( +
+ + + {groups.map((group) => ( + + ))} +
+ ) : ( +
+ setNewGroupName(value)} + autoFocus + /> +
+ )} +
+ ); +}; + +export default AddToGroupModal; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkRenameDialogs.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkRenameDialogs.tsx new file mode 100644 index 000000000..a3de8105b --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkRenameDialogs.tsx @@ -0,0 +1,118 @@ +import { variants } from "@/shared/components/Button"; +import Dialog from "@/shared/components/Dialog"; + +interface BaseBulkRenameDialogsProps { + open: boolean; + showDuplicateNamesWarning: boolean; + showNoChangesWarning: boolean; + duplicateNamesDialogBody: string; + noChangesDialogBody: string; + onDismissDuplicateNames: () => void; + onContinueDuplicateNames: () => void; + onDismissNoChanges: () => void; + onContinueNoChanges: () => void; +} + +type BulkRenameDialogsProps = + | (BaseBulkRenameDialogsProps & { + showOverwriteWarning?: false; + }) + | (BaseBulkRenameDialogsProps & { + showOverwriteWarning: true; + overwriteDialogTitle?: string; + overwriteDialogBody: string; + onDismissOverwriteWarning: () => void; + onContinueOverwriteWarning: () => void; + }); + +const BulkRenameDialogs = (props: BulkRenameDialogsProps) => { + const { + open, + showDuplicateNamesWarning, + showNoChangesWarning, + duplicateNamesDialogBody, + noChangesDialogBody, + onDismissDuplicateNames, + onContinueDuplicateNames, + onDismissNoChanges, + onContinueNoChanges, + } = props; + + return ( + <> + {showDuplicateNamesWarning ? ( + + ) : null} + + {showNoChangesWarning ? ( + + ) : null} + + {props.showOverwriteWarning ? ( + + ) : null} + + ); +}; + +export default BulkRenameDialogs; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkRenameModal.stories.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkRenameModal.stories.tsx new file mode 100644 index 000000000..adc92facf --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkRenameModal.stories.tsx @@ -0,0 +1,130 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { action } from "storybook/actions"; + +import { createDefaultBulkRenamePreferences } from "./bulkRenameDefinitions"; +import BulkRenamePreviewPanel, { type PreviewRow } from "./BulkRenamePreviewPanel"; +import BulkRenamePropertyForm from "./BulkRenamePropertyForm"; +import FullScreenTwoPaneModal from "@/protoFleet/components/FullScreenTwoPaneModal"; +import { variants } from "@/shared/components/Button"; +import { Toaster as ToasterComponent } from "@/shared/features/toaster"; + +const samplePreviewRows: PreviewRow[] = [ + { currentName: "miner-001", newName: "site-a-rack-1-001" }, + { currentName: "miner-002", newName: "site-a-rack-1-002" }, + { currentName: "miner-003", newName: "site-a-rack-1-003" }, + { currentName: "miner-004", newName: "site-a-rack-2-001" }, + { currentName: "miner-005", newName: "site-a-rack-2-002" }, + { currentName: "miner-006", newName: "site-a-rack-2-003" }, +]; + +type BulkRenameModalStoryProps = { + infoMessage: string; + isLoadingPreview?: boolean; + showPreviewEllipsis?: boolean; + minerCount?: number; +}; + +const BulkRenameModalStory = ({ + infoMessage, + isLoadingPreview = false, + showPreviewEllipsis = false, + minerCount = 6, +}: BulkRenameModalStoryProps) => { + const [open, setOpen] = useState(true); + const [preferences, setPreferences] = useState(createDefaultBulkRenamePreferences); + + if (!open) { + return ( +
+ +
+ ); + } + + return ( +
+
{infoMessage}
+
+ +
+ { + action("onDismiss")(); + setOpen(false); + }} + buttons={[ + { + text: `Apply to ${minerCount} miners`, + variant: variants.primary, + onClick: action("apply"), + }, + ]} + primaryPane={ + + } + secondaryPane={ + { + action("toggleEnabled")(propertyId, enabled); + setPreferences((current) => ({ + ...current, + properties: current.properties.map((p) => (p.id === propertyId ? { ...p, enabled } : p)), + })); + }} + onChangeSeparator={(separator) => { + action("changeSeparator")(separator); + setPreferences((current) => ({ ...current, separator })); + }} + /> + } + /> +
+ ); +}; + +const meta = { + title: "Proto Fleet/Fleet Management/Bulk Rename/BulkRenameModal", + component: BulkRenameModalStory, + parameters: { + layout: "fullscreen", + }, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + infoMessage: "Bulk rename modal with sample preview data and interactive property form.", + minerCount: 6, + }, +}; + +export const LoadingPreview: Story = { + args: { + infoMessage: "Bulk rename modal showing the loading state for the preview panel.", + isLoadingPreview: true, + }, +}; + +export const WithEllipsis: Story = { + args: { + infoMessage: "Bulk rename modal with ellipsis indicating more miners beyond the visible preview sample.", + showPreviewEllipsis: true, + minerCount: 150, + }, +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkRenameModal.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkRenameModal.tsx new file mode 100644 index 000000000..cc9a77db3 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkRenameModal.tsx @@ -0,0 +1,627 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { create } from "@bufbuild/protobuf"; +import { type DragEndEvent } from "@dnd-kit/core"; +import { + type BulkRenamePreferences, + type BulkRenamePreviewMiner, + type BulkRenamePropertyId, + type BulkRenamePropertyOptions, + reorderBulkRenameProperties, + shouldWarnAboutBulkRenameDuplicates, + updateBulkRenameProperty, +} from "./bulkRenameDefinitions"; +import BulkRenameDialogs from "./BulkRenameDialogs"; +import BulkRenameOptionModals from "./BulkRenameOptionModals"; +import { + buildBulkRenameConfig, + buildBulkRenamePropertyPreview, + evaluateBulkRenamePreviewName, + findBulkRenamePropertyPreviewMinerIndex, + mapSnapshotsToBulkRenamePreviewMiners, + mapSnapshotToBulkRenamePreviewMiner, + shouldShowBulkRenameNoChangesWarning, + takePreviewMiners, +} from "./bulkRenamePreview"; +import BulkRenamePreviewPanel, { type PreviewRow } from "./BulkRenamePreviewPanel"; +import BulkRenamePropertyForm from "./BulkRenamePropertyForm"; +import { + getBulkRenameFailureMessage, + getBulkRenameLoadingMessage, + getBulkRenameRequestFailureMessage, + getBulkRenameSuccessMessage, +} from "./bulkRenameToastMessages"; +import { + type CustomPropertyOptionsValues, + type FixedValueOptionsValues, + type QualifierOptionsValues, +} from "./RenameOptionsModals/types"; +import { fleetManagementClient } from "@/protoFleet/api/clients"; +import { DeviceIdentifierListSchema } from "@/protoFleet/api/generated/common/v1/device_selector_pb"; +import { + type SortConfig, + SortConfigSchema, + SortDirection, + SortField, +} from "@/protoFleet/api/generated/common/v1/sort_pb"; +import { + DeviceSelectorSchema, + type MinerListFilter, + type MinerStateSnapshot, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import useRenameMiners from "@/protoFleet/api/useRenameMiners"; +import FullScreenTwoPaneModal from "@/protoFleet/components/FullScreenTwoPaneModal"; +import { + applyFleetSelectablePairingStatuses, + isFleetSelectablePairingStatus, +} from "@/protoFleet/features/fleetManagement/utils/fleetVisiblePairingFilter"; +import { useAuthErrors, useBulkRenamePreferences, useSetBulkRenamePreferences } from "@/protoFleet/store"; +import { variants } from "@/shared/components/Button"; +import { type SelectionMode } from "@/shared/components/List"; +import { pushToast, removeToast, STATUSES as TOAST_STATUSES, updateToast } from "@/shared/features/toaster"; +import { useWindowDimensions } from "@/shared/hooks/useWindowDimensions"; + +interface BulkRenameModalProps { + open: boolean; + selectedMinerIds: string[]; + selectionMode: SelectionMode; + totalCount?: number; + currentFilter?: MinerListFilter; + currentSort?: SortConfig; + miners: Record; + minerIds: string[]; + onRefetchMiners?: () => void; + onDismiss: () => void; +} + +const duplicateNamesDialogBody = + "Some miners may have duplicate names. Proceeding may impact accuracy in operations and reporting. Do you want to continue anyway?"; +const noChangesDialogBody = + "You can continue to retain your existing miner names, or keep editing. Do you want to continue anyway?"; +const emptyOptionsPreview = { + previewName: "", + highlightedText: undefined, + highlightStartIndex: undefined, +} as const; + +const getSelectionCount = (selectionMode: SelectionMode, selectedMinerIds: string[], totalCount?: number): number => { + if (selectionMode === "all") { + return totalCount ?? selectedMinerIds.length; + } + + return selectedMinerIds.length; +}; + +const computePreviewNames = (preferences: BulkRenamePreferences, previewMiners: BulkRenamePreviewMiner[]): string[] => { + const config = buildBulkRenameConfig(preferences); + return previewMiners.map((miner) => evaluateBulkRenamePreviewName(config, miner, miner.counterIndex)); +}; + +const getPreviewRows = (previewMiners: BulkRenamePreviewMiner[], previewNames: string[]): PreviewRow[] => + previewMiners.map((miner, index) => ({ + currentName: miner.currentName, + newName: previewNames[index] ?? "", + })); + +const buildOptionsPreviewPreferences = ( + preferences: BulkRenamePreferences, + propertyId: BulkRenamePropertyId, + options: BulkRenamePropertyOptions | null, +): BulkRenamePreferences => + updateBulkRenameProperty(preferences, propertyId, (property) => ({ + ...property, + enabled: true, + options: options ?? property.options, + })); + +const BulkRenameModal = ({ + open, + selectedMinerIds, + selectionMode, + totalCount, + currentFilter, + currentSort, + miners: minersById, + minerIds, + onRefetchMiners, + onDismiss, +}: BulkRenameModalProps) => { + const bulkRenamePreferences = useBulkRenamePreferences(); + const setBulkRenamePreferences = useSetBulkRenamePreferences(); + const { handleAuthErrors } = useAuthErrors(); + const { renameMiners } = useRenameMiners(); + const { isPhone, isTablet } = useWindowDimensions(); + + const [previewMiners, setPreviewMiners] = useState([]); + const [previewNames, setPreviewNames] = useState([]); + const [showPreviewEllipsis, setShowPreviewEllipsis] = useState(false); + const [isLoadingPreview, setIsLoadingPreview] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [activeOptionsPropertyId, setActiveOptionsPropertyId] = useState(null); + const [activeOptionsDraft, setActiveOptionsDraft] = useState(null); + const [showDuplicateNamesWarning, setShowDuplicateNamesWarning] = useState(false); + const [showNoChangesWarning, setShowNoChangesWarning] = useState(false); + const bulkRenamePreferencesRef = useRef(bulkRenamePreferences); + const previewMinersRef = useRef(previewMiners); + + const selectionCount = useMemo( + () => getSelectionCount(selectionMode, selectedMinerIds, totalCount), + [selectionMode, selectedMinerIds, totalCount], + ); + const previewSampleSize = useMemo(() => (isPhone || isTablet ? 1 : 6), [isPhone, isTablet]); + const selectedMinerIdSet = useMemo(() => new Set(selectedMinerIds), [selectedMinerIds]); + + const localPreviewMiners = useMemo(() => { + if (selectionMode === "subset") { + return mapSnapshotsToBulkRenamePreviewMiners( + minerIds + .filter((deviceIdentifier) => selectedMinerIdSet.has(deviceIdentifier)) + .map((deviceIdentifier) => minersById[deviceIdentifier]) + .filter((miner): miner is NonNullable => miner !== undefined), + ); + } + + return mapSnapshotsToBulkRenamePreviewMiners( + minerIds + .map((deviceIdentifier) => minersById[deviceIdentifier]) + .filter( + (miner): miner is NonNullable => + miner !== undefined && isFleetSelectablePairingStatus(miner.pairingStatus), + ), + ); + }, [minerIds, minersById, selectedMinerIdSet, selectionMode]); + + const localValidationMiners = useMemo(() => { + if (selectionMode === "subset") { + return localPreviewMiners.length === selectedMinerIds.length ? localPreviewMiners : null; + } + + return localPreviewMiners.length === selectionCount ? localPreviewMiners : null; + }, [localPreviewMiners, selectedMinerIds.length, selectionCount, selectionMode]); + + const loadPreviewMiners = useCallback(async (): Promise<{ + miners: BulkRenamePreviewMiner[]; + showEllipsis: boolean; + }> => { + if (selectionMode === "subset") { + return takePreviewMiners(localPreviewMiners, localPreviewMiners.length, previewSampleSize); + } + + if (previewSampleSize === 1) { + const filter = applyFleetSelectablePairingStatuses(currentFilter); + const sort = currentSort ? [currentSort] : []; + const response = await fleetManagementClient.listMinerStateSnapshots({ + pageSize: 1, + filter, + sort, + }); + + return { + miners: mapSnapshotsToBulkRenamePreviewMiners(response.miners), + showEllipsis: false, + }; + } + + if (localValidationMiners !== null) { + return takePreviewMiners(localValidationMiners, selectionCount, previewSampleSize); + } + + const filter = applyFleetSelectablePairingStatuses(currentFilter); + const sort = currentSort ? [currentSort] : []; + const reverseSort = currentSort + ? [ + create(SortConfigSchema, { + field: currentSort.field, + direction: currentSort.direction === SortDirection.DESC ? SortDirection.ASC : SortDirection.DESC, + }), + ] + : [ + create(SortConfigSchema, { + field: SortField.NAME, + direction: SortDirection.DESC, + }), + ]; + + if (selectionCount <= previewSampleSize) { + const response = await fleetManagementClient.listMinerStateSnapshots({ + pageSize: selectionCount, + filter, + sort, + }); + + return { + miners: mapSnapshotsToBulkRenamePreviewMiners(response.miners), + showEllipsis: false, + }; + } + + const headPreviewCount = Math.floor(previewSampleSize / 2); + const tailPreviewCount = previewSampleSize - headPreviewCount; + + const [firstResponse, lastResponse] = await Promise.all([ + fleetManagementClient.listMinerStateSnapshots({ + pageSize: headPreviewCount, + filter, + sort, + }), + fleetManagementClient.listMinerStateSnapshots({ + pageSize: tailPreviewCount, + filter, + sort: reverseSort, + }), + ]); + + return { + miners: [ + ...firstResponse.miners.map((miner, index) => mapSnapshotToBulkRenamePreviewMiner(miner, index)), + ...lastResponse.miners + .map((miner, index) => mapSnapshotToBulkRenamePreviewMiner(miner, selectionCount - index - 1)) + .reverse(), + ], + showEllipsis: true, + }; + }, [ + currentFilter, + currentSort, + localValidationMiners, + localPreviewMiners, + previewSampleSize, + selectionCount, + selectionMode, + ]); + + useEffect(() => { + if (!open) { + setActiveOptionsPropertyId(null); + setActiveOptionsDraft(null); + setShowDuplicateNamesWarning(false); + setShowNoChangesWarning(false); + setPreviewMiners([]); + setPreviewNames([]); + setShowPreviewEllipsis(false); + setIsLoadingPreview(false); + return; + } + + let cancelled = false; + + const load = async () => { + setIsLoadingPreview(true); + + try { + const previewResult = await loadPreviewMiners(); + + if (cancelled) { + return; + } + + setPreviewMiners(previewResult.miners); + setShowPreviewEllipsis(previewResult.showEllipsis); + } catch (error) { + handleAuthErrors({ + error, + onError: () => { + if (cancelled) { + return; + } + setPreviewMiners([]); + setShowPreviewEllipsis(false); + }, + }); + } finally { + if (!cancelled) { + setIsLoadingPreview(false); + } + } + }; + + void load(); + + return () => { + cancelled = true; + }; + }, [handleAuthErrors, loadPreviewMiners, open]); + + useEffect(() => { + bulkRenamePreferencesRef.current = bulkRenamePreferences; + }, [bulkRenamePreferences]); + + useEffect(() => { + previewMinersRef.current = previewMiners; + }, [previewMiners]); + + useEffect(() => { + if (!open) { + return; + } + + setPreviewNames(computePreviewNames(bulkRenamePreferencesRef.current, previewMiners)); + }, [open, previewMiners]); + + useEffect(() => { + if (!open) { + return; + } + + const timeoutId = window.setTimeout(() => { + setPreviewNames(computePreviewNames(bulkRenamePreferences, previewMinersRef.current)); + }, 500); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [bulkRenamePreferences, open]); + + const handleToggleEnabled = useCallback( + (propertyId: BulkRenamePropertyId, enabled: boolean) => { + setBulkRenamePreferences( + updateBulkRenameProperty(bulkRenamePreferences, propertyId, (property) => ({ + ...property, + enabled, + })), + ); + }, + [bulkRenamePreferences, setBulkRenamePreferences], + ); + + const handleUpdateOptions = useCallback( + ( + propertyId: BulkRenamePropertyId, + options: CustomPropertyOptionsValues | FixedValueOptionsValues | QualifierOptionsValues, + ) => { + setBulkRenamePreferences( + updateBulkRenameProperty(bulkRenamePreferences, propertyId, (property) => ({ + ...property, + options, + })), + ); + setActiveOptionsDraft(null); + setActiveOptionsPropertyId(null); + }, + [bulkRenamePreferences, setBulkRenamePreferences], + ); + + const handleDismissOptions = useCallback(() => { + setActiveOptionsDraft(null); + setActiveOptionsPropertyId(null); + }, []); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + + if (!over || active.id === over.id) { + return; + } + + setBulkRenamePreferences( + reorderBulkRenameProperties( + bulkRenamePreferences, + active.id as BulkRenamePropertyId, + over.id as BulkRenamePropertyId, + ), + ); + }, + [bulkRenamePreferences, setBulkRenamePreferences], + ); + + const proceedWithSubmit = useCallback(async () => { + const allDevicesFilter = applyFleetSelectablePairingStatuses(currentFilter); + const config = buildBulkRenameConfig(bulkRenamePreferences); + + const deviceSelector = create(DeviceSelectorSchema, { + selectionType: + selectionMode === "all" + ? { + case: "allDevices", + value: allDevicesFilter, + } + : { + case: "includeDevices", + value: create(DeviceIdentifierListSchema, { + deviceIdentifiers: selectedMinerIds, + }), + }, + }); + + const toastId = pushToast({ + message: getBulkRenameLoadingMessage(selectionCount), + status: TOAST_STATUSES.loading, + longRunning: true, + }); + + setIsSubmitting(true); + + try { + const response = await renameMiners(deviceSelector, config, currentSort); + onRefetchMiners?.(); + + if (response.renamedCount > 0 || response.unchangedCount > 0) { + updateToast(toastId, { + message: getBulkRenameSuccessMessage(response.renamedCount, response.unchangedCount), + status: TOAST_STATUSES.success, + }); + } else { + removeToast(toastId); + } + + if (response.failedCount > 0) { + pushToast({ + message: getBulkRenameFailureMessage(response.failedCount), + status: TOAST_STATUSES.error, + longRunning: true, + }); + } + + onDismiss(); + } catch { + updateToast(toastId, { + message: getBulkRenameRequestFailureMessage(selectionCount), + status: TOAST_STATUSES.error, + }); + } finally { + setIsSubmitting(false); + } + }, [ + bulkRenamePreferences, + currentFilter, + onDismiss, + onRefetchMiners, + renameMiners, + currentSort, + selectedMinerIds, + selectionCount, + selectionMode, + ]); + + const noChangeValidationMiners = useMemo(() => { + if (previewMiners.length === selectionCount) { + return previewMiners; + } + + if (localValidationMiners !== null) { + return localValidationMiners; + } + + return null; + }, [localValidationMiners, previewMiners, selectionCount]); + + const shouldShowNoChangesWarning = useMemo( + () => shouldShowBulkRenameNoChangesWarning(bulkRenamePreferences, noChangeValidationMiners), + [bulkRenamePreferences, noChangeValidationMiners], + ); + + const handleSubmit = useCallback(() => { + // The visible preview is capped to a small head/tail sample for large selections. We only show the no-change + // dialog when we can validate against the full selection from data already in memory; otherwise we avoid extra + // miner-loading API calls in the UI and let the backend handle the bulk rename request. + if (shouldShowNoChangesWarning) { + setShowNoChangesWarning(true); + return; + } + + if (shouldWarnAboutBulkRenameDuplicates(selectionCount, bulkRenamePreferences, noChangeValidationMiners)) { + setShowDuplicateNamesWarning(true); + return; + } + + void proceedWithSubmit(); + }, [bulkRenamePreferences, noChangeValidationMiners, proceedWithSubmit, selectionCount, shouldShowNoChangesWarning]); + + const handleDuplicateNamesContinue = useCallback(() => { + setShowDuplicateNamesWarning(false); + + if (shouldShowNoChangesWarning) { + setShowNoChangesWarning(true); + return; + } + + void proceedWithSubmit(); + }, [proceedWithSubmit, shouldShowNoChangesWarning]); + + const activeOptionsProperty = useMemo( + () => bulkRenamePreferences.properties.find((property) => property.id === activeOptionsPropertyId) ?? null, + [activeOptionsPropertyId, bulkRenamePreferences.properties], + ); + + const activeOptionsPreview = useMemo(() => { + if (activeOptionsProperty === null || previewMiners.length === 0) { + return emptyOptionsPreview; + } + + const previewPreferences = buildOptionsPreviewPreferences( + bulkRenamePreferences, + activeOptionsProperty.id, + activeOptionsDraft, + ); + + const previewMinerIndex = findBulkRenamePropertyPreviewMinerIndex( + previewPreferences, + activeOptionsProperty.id, + previewMiners, + ); + + if (previewMinerIndex === null) { + return emptyOptionsPreview; + } + + return buildBulkRenamePropertyPreview( + previewPreferences, + activeOptionsProperty.id, + previewMiners[previewMinerIndex], + previewMiners[previewMinerIndex].counterIndex, + ); + }, [activeOptionsDraft, activeOptionsProperty, bulkRenamePreferences, previewMiners]); + + const previewRows = useMemo(() => getPreviewRows(previewMiners, previewNames), [previewMiners, previewNames]); + const isBusy = isSubmitting; + + return ( + <> + void handleSubmit(), + disabled: isBusy || isLoadingPreview, + testId: "bulk-rename-save-button", + }, + ]} + primaryPane={ + + setBulkRenamePreferences({ + ...bulkRenamePreferences, + separator, + }) + } + /> + } + secondaryPane={ + + } + /> + + setShowDuplicateNamesWarning(false)} + onContinueDuplicateNames={handleDuplicateNamesContinue} + onDismissNoChanges={() => setShowNoChangesWarning(false)} + onContinueNoChanges={() => { + setShowNoChangesWarning(false); + onDismiss(); + }} + /> + + + + ); +}; + +export default BulkRenameModal; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkRenameOptionModals.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkRenameOptionModals.tsx new file mode 100644 index 000000000..84c753b73 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkRenameOptionModals.tsx @@ -0,0 +1,83 @@ +import { + type BulkRenamePropertyId, + type BulkRenamePropertyOptions, + getBulkRenamePropertyDefinition, +} from "./bulkRenameDefinitions"; +import CustomPropertyOptionsModal from "./RenameOptionsModals/CustomPropertyOptionsModal"; +import FixedValueOptionsModal from "./RenameOptionsModals/FixedValueOptionsModal"; +import QualifierOptionsModal from "./RenameOptionsModals/QualifierOptionsModal"; +import { + type CustomPropertyOptionsValues, + type FixedValueOptionsValues, + type QualifierOptionsValues, +} from "./RenameOptionsModals/types"; + +interface BulkRenameOptionModalProps { + activeOptionsPropertyId: BulkRenamePropertyId | null; + activeOptionsPropertyOptions: BulkRenamePropertyOptions | null; + previewName: string; + highlightedText?: string; + highlightStartIndex?: number; + onDismiss: () => void; + onChange: (options: BulkRenamePropertyOptions | null) => void; + onConfirm: ( + propertyId: BulkRenamePropertyId, + options: CustomPropertyOptionsValues | FixedValueOptionsValues | QualifierOptionsValues, + ) => void; +} + +const BulkRenameOptionModals = ({ + activeOptionsPropertyId, + activeOptionsPropertyOptions, + previewName, + highlightedText, + highlightStartIndex, + onDismiss, + onChange, + onConfirm, +}: BulkRenameOptionModalProps) => { + if (activeOptionsPropertyId === null || activeOptionsPropertyOptions === null) { + return null; + } + + const sharedProps = { + open: true, + previewName, + highlightedText, + highlightStartIndex, + onDismiss, + onChange, + }; + + const activeOptionsKind = getBulkRenamePropertyDefinition(activeOptionsPropertyId).kind; + + if (activeOptionsKind === "custom") { + return ( + onConfirm(activeOptionsPropertyId, options)} + /> + ); + } + + if (activeOptionsKind === "fixed") { + return ( + onConfirm(activeOptionsPropertyId, options)} + /> + ); + } + + return ( + onConfirm(activeOptionsPropertyId, options)} + /> + ); +}; + +export default BulkRenameOptionModals; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkRenamePreviewPanel.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkRenamePreviewPanel.tsx new file mode 100644 index 000000000..246ca8959 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkRenamePreviewPanel.tsx @@ -0,0 +1,91 @@ +import NamePreview from "@/shared/components/NamePreview"; +import ProgressCircular from "@/shared/components/ProgressCircular"; + +export interface PreviewRow { + currentName: string; + newName: string; +} + +interface BulkRenamePreviewPanelProps { + isLoadingPreview: boolean; + previewRows: PreviewRow[]; + showPreviewEllipsis: boolean; +} + +const BulkRenamePreviewPanel = ({ + isLoadingPreview, + previewRows, + showPreviewEllipsis, +}: BulkRenamePreviewPanelProps) => { + const mobilePreviewRow = previewRows[0]; + + return ( + <> +
+ {isLoadingPreview ? ( + + ) : mobilePreviewRow ? ( +
+ +
+ ) : ( +
No preview available
+ )} +
+ +
+ {isLoadingPreview ? ( +
+ +
+ ) : previewRows.length === 0 ? ( +
+ No preview available +
+ ) : ( +
+
+ {previewRows.slice(0, showPreviewEllipsis ? 3 : previewRows.length).map((row, index) => ( + + ))} + + {showPreviewEllipsis ? ( +
...
+ ) : null} + + {showPreviewEllipsis + ? previewRows + .slice(-3) + .map((row, index) => ( + + )) + : null} +
+
+ )} +
+ + ); +}; + +export default BulkRenamePreviewPanel; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkRenamePropertyForm.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkRenamePropertyForm.tsx new file mode 100644 index 000000000..2e1d858be --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkRenamePropertyForm.tsx @@ -0,0 +1,168 @@ +import { type ReactNode } from "react"; +import { + closestCenter, + DndContext, + type DragEndEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { + type BulkRenamePreferences, + type BulkRenamePropertyId, + type BulkRenamePropertyState, + type BulkRenameSeparatorId, + bulkRenameSeparators, + getBulkRenamePropertyDefinition, +} from "./bulkRenameDefinitions"; +import { Grip, Slider } from "@/shared/assets/icons"; +import Radio from "@/shared/components/Radio"; +import Switch from "@/shared/components/Switch"; + +interface BulkRenamePropertyFormProps { + preferences: BulkRenamePreferences; + onDragEnd: (event: DragEndEvent) => void; + onOpenOptions: (propertyId: BulkRenamePropertyId) => void; + onToggleEnabled: (propertyId: BulkRenamePropertyId, enabled: boolean) => void; + onChangeSeparator: (separatorId: BulkRenameSeparatorId) => void; + propertiesTitle?: string; + separatorTitle?: string; + leadingContent?: ReactNode; +} + +interface SortablePropertyRowProps { + property: BulkRenamePropertyState; + onOpenOptions: (propertyId: BulkRenamePropertyId) => void; + onToggleEnabled: (propertyId: BulkRenamePropertyId, enabled: boolean) => void; +} + +const SortablePropertyRow = ({ property, onOpenOptions, onToggleEnabled }: SortablePropertyRowProps) => { + const definition = getBulkRenamePropertyDefinition(property.id); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: property.id }); + + return ( +
+
+ + + + +
+ {property.enabled ? ( + + ) : null} + onToggleEnabled(property.id, !property.enabled)} /> +
+
+
+ ); +}; + +const BulkRenamePropertyForm = ({ + preferences, + onDragEnd, + onOpenOptions, + onToggleEnabled, + onChangeSeparator, + propertiesTitle = "Name properties", + separatorTitle = "Property separator", + leadingContent, +}: BulkRenamePropertyFormProps) => { + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + return ( +
+ {leadingContent} + +
+

{propertiesTitle}

+ + + property.id)} + strategy={verticalListSortingStrategy} + > +
+ {preferences.properties.map((property) => ( + + ))} +
+
+
+
+ +
+

{separatorTitle}

+
+ {Object.entries(bulkRenameSeparators).map(([separatorId, separator]) => ( + + ))} +
+
+
+ ); +}; + +export default BulkRenamePropertyForm; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkRenameToasts.stories.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkRenameToasts.stories.tsx new file mode 100644 index 000000000..cd314726e --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkRenameToasts.stories.tsx @@ -0,0 +1,116 @@ +import { useEffect } from "react"; + +import { + getBulkRenameFailureMessage, + getBulkRenameLoadingMessage, + getBulkRenameRequestFailureMessage, + getBulkRenameSuccessMessage, +} from "@/protoFleet/features/fleetManagement/components/MinerActionsMenu/bulkRenameToastMessages"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import { + clearToasts, + pushToast, + removeToast, + STATUSES, + Toaster as ToasterComponent, + updateToast, +} from "@/shared/features/toaster"; + +interface BulkRenameToastStoryProps { + failedCount: number; + renamedCount: number; + selectionCount: number; + unchangedCount: number; + requestFailed?: boolean; +} + +const playBulkRenameToastScenario = ({ + failedCount, + renamedCount, + selectionCount, + unchangedCount, + requestFailed = false, +}: BulkRenameToastStoryProps) => { + clearToasts(); + + const toastId = pushToast({ + message: getBulkRenameLoadingMessage(selectionCount), + status: STATUSES.loading, + longRunning: true, + }); + + if (requestFailed) { + updateToast(toastId, { + message: getBulkRenameRequestFailureMessage(selectionCount), + status: STATUSES.error, + }); + return; + } + + if (renamedCount > 0 || unchangedCount > 0) { + updateToast(toastId, { + message: getBulkRenameSuccessMessage(renamedCount, unchangedCount), + status: STATUSES.success, + }); + } else { + removeToast(toastId); + } + + if (failedCount > 0) { + pushToast({ + message: getBulkRenameFailureMessage(failedCount), + status: STATUSES.error, + longRunning: true, + }); + } +}; + +const StoryLayout = (props: BulkRenameToastStoryProps) => { + useEffect(() => { + playBulkRenameToastScenario(props); + + return () => { + clearToasts(); + }; + }, [props]); + + return ( +
+
+ +

+ This story replays the exact toast copy used by bulk rename for the selected result combination. +

+
+
+ +
+
+ ); +}; + +export const RenamedOnly = () => ; + +export const UnchangedOnly = () => ( + +); + +export const RenamedAndUnchanged = () => ( + +); + +export const RenamedUnchangedAndFailed = () => ( + +); + +export const FailedOnly = () => ; + +export const RequestFailure = () => ( + +); + +export default { + title: "Proto Fleet/Fleet Management/Bulk Rename/Toasts", +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkWorkerNameModal.test.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkWorkerNameModal.test.tsx new file mode 100644 index 000000000..2aac1755e --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkWorkerNameModal.test.tsx @@ -0,0 +1,778 @@ +import { type ComponentProps, type ReactNode } from "react"; +import { act, render, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import { + bulkRenameModes, + bulkRenamePropertyIds, + createDefaultBulkRenamePreferences, + updateBulkRenameProperty, +} from "./bulkRenameDefinitions"; +import BulkWorkerNameModal from "./BulkWorkerNameModal"; +import { customPropertyTypes } from "./RenameOptionsModals/types"; +import { SortConfigSchema, SortDirection, SortField } from "@/protoFleet/api/generated/common/v1/sort_pb"; +import { + type MinerStateSnapshot, + MinerStateSnapshotSchema, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +const { + mockBulkRenameDialogs, + mockCompleteBatchOperation, + mockFullScreenTwoPaneModal, + mockHandleAuthErrors, + mockListMinerStateSnapshots, + mockPushToast, + mockRemoveToast, + mockStartBatchOperation, + mockStreamCommandBatchUpdates, + mockUpdateToast, + mockUseBulkWorkerNamePreferences, + mockUseSetBulkWorkerNamePreferences, + mockUpdateWorkerNames, +} = vi.hoisted(() => ({ + mockBulkRenameDialogs: vi.fn(() => null), + mockCompleteBatchOperation: vi.fn(), + mockFullScreenTwoPaneModal: vi.fn(() => null), + mockHandleAuthErrors: vi.fn(), + mockListMinerStateSnapshots: vi.fn(), + mockPushToast: vi.fn(), + mockRemoveToast: vi.fn(), + mockStartBatchOperation: vi.fn(), + mockStreamCommandBatchUpdates: vi.fn(), + mockUpdateToast: vi.fn(), + mockUseBulkWorkerNamePreferences: vi.fn(), + mockUseSetBulkWorkerNamePreferences: vi.fn(), + mockUpdateWorkerNames: vi.fn(), +})); + +const workerNamePreferences = updateBulkRenameProperty( + createDefaultBulkRenamePreferences(bulkRenameModes.worker), + bulkRenamePropertyIds.custom, + (property) => ({ + ...property, + enabled: true, + options: { + ...property.options, + type: customPropertyTypes.stringAndCounter, + prefix: "worker-", + suffix: "", + counterStart: 1, + counterScale: 1, + }, + }), +); + +type MinerOverrides = Partial>; + +vi.mock("@/protoFleet/store", () => ({ + useAuthErrors: vi.fn(() => ({ + handleAuthErrors: mockHandleAuthErrors, + })), + useBulkWorkerNamePreferences: mockUseBulkWorkerNamePreferences, + useSetBulkWorkerNamePreferences: mockUseSetBulkWorkerNamePreferences, +})); + +vi.mock("@/protoFleet/api/clients", () => ({ + fleetManagementClient: { + listMinerStateSnapshots: mockListMinerStateSnapshots, + }, +})); + +vi.mock("@/protoFleet/api/useUpdateWorkerNames", () => ({ + default: vi.fn(() => ({ + updateWorkerNames: mockUpdateWorkerNames, + })), +})); + +vi.mock("@/protoFleet/api/useMinerCommand", () => ({ + useMinerCommand: vi.fn(() => ({ + streamCommandBatchUpdates: mockStreamCommandBatchUpdates, + })), +})); + +vi.mock("@/protoFleet/features/fleetManagement/hooks/useBatchOperations", () => ({ + useBatchOperations: vi.fn(() => ({ + startBatchOperation: mockStartBatchOperation, + completeBatchOperation: mockCompleteBatchOperation, + })), +})); + +vi.mock("@/shared/hooks/useWindowDimensions", () => ({ + useWindowDimensions: vi.fn(() => ({ + isPhone: false, + isTablet: false, + })), +})); + +vi.mock("@/protoFleet/components/FullScreenTwoPaneModal", () => ({ + default: mockFullScreenTwoPaneModal, +})); + +vi.mock("./BulkRenamePropertyForm", () => ({ + default: () =>
, +})); + +vi.mock("./BulkRenamePreviewPanel", () => ({ + default: () =>
, +})); + +vi.mock("./BulkRenameDialogs", () => ({ + default: mockBulkRenameDialogs, +})); + +vi.mock("./BulkRenameOptionModals", () => ({ + default: () => null, +})); + +vi.mock("@/shared/components/Callout", () => ({ + default: () => null, +})); + +vi.mock("@/shared/features/toaster", () => ({ + pushToast: mockPushToast, + removeToast: mockRemoveToast, + updateToast: mockUpdateToast, + STATUSES: { + loading: "loading", + success: "success", + error: "error", + }, +})); + +const makeMiner = (deviceIdentifier: string, name: string, workerName = "", overrides: MinerOverrides = {}) => + create(MinerStateSnapshotSchema, { + deviceIdentifier, + name, + workerName, + manufacturer: "Bitmain", + model: "S19", + macAddress: `${deviceIdentifier}-mac`, + serialNumber: `${deviceIdentifier}-serial`, + rackLabel: "", + rackPosition: "", + ...overrides, + }); + +const renderModal = (props: Partial> = {}) => + render( + ({ + username: "testuser", + password: "testpass", + })} + onDismiss={vi.fn()} + {...props} + />, + ); + +const getLatestFullScreenModalProps = () => { + const fullScreenModalCalls = mockFullScreenTwoPaneModal.mock.calls as unknown as Array< + [ + { + buttons: Array<{ onClick: () => void }>; + primaryPane: ReactNode; + secondaryPane: ReactNode; + }, + ] + >; + const latestFullScreenModalProps = fullScreenModalCalls[fullScreenModalCalls.length - 1]?.[0]; + if (latestFullScreenModalProps === undefined) { + throw new Error("FullScreenTwoPaneModal was not rendered with props"); + } + return latestFullScreenModalProps; +}; + +const getLatestPreviewPanelProps = () => { + const latestPreviewPanelProps = ( + getLatestFullScreenModalProps().secondaryPane as { + props?: { previewRows?: Array<{ currentName: string; newName: string }> }; + } + )?.props; + if (latestPreviewPanelProps === undefined) { + throw new Error("BulkRenamePreviewPanel was not rendered with props"); + } + return latestPreviewPanelProps; +}; + +describe("BulkWorkerNameModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPushToast.mockReturnValueOnce(1).mockReturnValueOnce(2); + mockUseBulkWorkerNamePreferences.mockReturnValue(workerNamePreferences); + mockUseSetBulkWorkerNamePreferences.mockReturnValue(vi.fn()); + }); + + it("waits for the batch result, tracks the batch, updates successful visible rows, and shows mixed-result toasts", async () => { + mockUpdateWorkerNames.mockResolvedValue({ + updatedCount: 3, + unchangedCount: 0, + failedCount: 0, + batchIdentifier: "batch-1", + }); + mockStreamCommandBatchUpdates.mockImplementation(async ({ onStreamData }) => { + onStreamData({ + status: { + commandBatchDeviceCount: { + total: 3, + success: 1, + failure: 2, + successDeviceIdentifiers: ["miner-2"], + failureDeviceIdentifiers: ["miner-1", "miner-3"], + }, + }, + }); + }); + + const onDismiss = vi.fn(); + const onRefetchMiners = vi.fn(); + const onWorkerNameUpdated = vi.fn(); + + renderModal({ + onDismiss, + onRefetchMiners, + onWorkerNameUpdated, + }); + + await waitFor(() => { + expect(mockFullScreenTwoPaneModal).toHaveBeenCalled(); + }); + + getLatestFullScreenModalProps().buttons[0]?.onClick(); + + await waitFor(() => { + expect(mockUpdateWorkerNames).toHaveBeenCalledTimes(1); + expect(mockStreamCommandBatchUpdates).toHaveBeenCalledTimes(1); + }); + + expect(mockStartBatchOperation).toHaveBeenCalledWith({ + batchIdentifier: "batch-1", + action: "update-worker-names", + deviceIdentifiers: ["miner-1", "miner-2", "miner-3"], + }); + expect(mockCompleteBatchOperation).toHaveBeenCalledWith("batch-1"); + expect(mockPushToast).toHaveBeenNthCalledWith(1, { + message: "Updating worker names", + status: "loading", + longRunning: true, + }); + expect(mockUpdateToast).toHaveBeenCalledWith(1, { + message: "Updated 1 miner", + status: "success", + }); + expect(mockPushToast).toHaveBeenNthCalledWith(2, { + message: "Failed to update worker names for 2 miners", + status: "error", + longRunning: true, + }); + expect(onWorkerNameUpdated).toHaveBeenCalledTimes(1); + expect(onWorkerNameUpdated).toHaveBeenCalledWith("miner-2", "worker-2"); + expect(onRefetchMiners).toHaveBeenCalledTimes(1); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it("returns to the miners table when the no-changes warning is confirmed", async () => { + mockUpdateWorkerNames.mockResolvedValue({ + updatedCount: 0, + unchangedCount: 2, + failedCount: 0, + }); + + const onDismiss = vi.fn(); + + renderModal({ + selectedMinerIds: ["miner-1", "miner-2"], + totalCount: 2, + miners: { + "miner-1": makeMiner("miner-1", "Miner 1", "worker-1"), + "miner-2": makeMiner("miner-2", "Miner 2", "worker-2"), + }, + minerIds: ["miner-1", "miner-2"], + onDismiss, + }); + + await waitFor(() => { + expect(mockFullScreenTwoPaneModal).toHaveBeenCalled(); + }); + + getLatestFullScreenModalProps().buttons[0]?.onClick(); + + await waitFor(() => { + expect( + ( + mockBulkRenameDialogs.mock.calls as unknown as Array< + [{ showNoChangesWarning: boolean; onContinueNoChanges: () => void }] + > + ) + .map(([props]) => props) + .find((props) => props.showNoChangesWarning), + ).toBeDefined(); + }); + + const latestDialogProps = ( + mockBulkRenameDialogs.mock.calls as unknown as Array< + [{ showNoChangesWarning: boolean; onContinueNoChanges: () => void }] + > + ) + .map(([props]) => props) + .find((props) => props.showNoChangesWarning); + + await act(async () => { + latestDialogProps?.onContinueNoChanges(); + }); + + expect(mockUpdateWorkerNames).not.toHaveBeenCalled(); + expect(mockPushToast).not.toHaveBeenCalled(); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it("returns to the miners table without validation errors when no worker-name properties are enabled", async () => { + mockUseBulkWorkerNamePreferences.mockReturnValue(createDefaultBulkRenamePreferences(bulkRenameModes.worker)); + + const onDismiss = vi.fn(); + + renderModal({ + selectedMinerIds: ["miner-1", "miner-2"], + totalCount: 2, + onDismiss, + }); + + await waitFor(() => { + expect(mockFullScreenTwoPaneModal).toHaveBeenCalled(); + }); + + getLatestFullScreenModalProps().buttons[0]?.onClick(); + + const latestDialogProps = await waitFor(() => { + const dialogProps = ( + mockBulkRenameDialogs.mock.calls as unknown as Array< + [{ showNoChangesWarning: boolean; onContinueNoChanges: () => void }] + > + ) + .map(([props]) => props) + .find((props) => props.showNoChangesWarning); + + expect(dialogProps).toBeDefined(); + return dialogProps; + }); + + await act(async () => { + latestDialogProps?.onContinueNoChanges(); + }); + + expect(mockUpdateWorkerNames).not.toHaveBeenCalled(); + expect(mockPushToast).not.toHaveBeenCalled(); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it("updates visible worker names immediately when the request completes without a batch", async () => { + mockUpdateWorkerNames.mockResolvedValue({ + updatedCount: 3, + unchangedCount: 0, + failedCount: 0, + }); + + const onDismiss = vi.fn(); + const onRefetchMiners = vi.fn(); + const onWorkerNameUpdated = vi.fn(); + + renderModal({ + onDismiss, + onRefetchMiners, + onWorkerNameUpdated, + }); + + await waitFor(() => { + expect(mockFullScreenTwoPaneModal).toHaveBeenCalled(); + }); + + await act(async () => { + getLatestFullScreenModalProps().buttons[0]?.onClick(); + }); + + await waitFor(() => { + expect(mockUpdateWorkerNames).toHaveBeenCalledTimes(1); + }); + + const loadingToastId = mockPushToast.mock.results[0]?.value; + + expect(mockUpdateToast).toHaveBeenCalledWith(loadingToastId, { + message: "Updated 3 miners", + status: "success", + }); + expect(onWorkerNameUpdated).toHaveBeenCalledTimes(3); + expect(onWorkerNameUpdated).toHaveBeenNthCalledWith(1, "miner-1", "worker-1"); + expect(onWorkerNameUpdated).toHaveBeenNthCalledWith(2, "miner-2", "worker-2"); + expect(onWorkerNameUpdated).toHaveBeenNthCalledWith(3, "miner-3", "worker-3"); + expect(onRefetchMiners).toHaveBeenCalledTimes(1); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it("sorts local subset previews and visible updates with the default preview sort", async () => { + mockUpdateWorkerNames.mockResolvedValue({ + updatedCount: 2, + unchangedCount: 0, + failedCount: 0, + }); + + const onWorkerNameUpdated = vi.fn(); + + renderModal({ + selectedMinerIds: ["miner-beta", "miner-alpha"], + totalCount: 2, + miners: { + "miner-alpha": makeMiner("miner-alpha", "Alpha", "alpha-worker"), + "miner-beta": makeMiner("miner-beta", "Beta", "beta-worker"), + }, + minerIds: ["miner-beta", "miner-alpha"], + onWorkerNameUpdated, + }); + + await waitFor(() => { + expect(getLatestPreviewPanelProps().previewRows).toEqual([ + { + currentName: "alpha-worker", + newName: "worker-1", + }, + { + currentName: "beta-worker", + newName: "worker-2", + }, + ]); + }); + + await act(async () => { + getLatestFullScreenModalProps().buttons[0]?.onClick(); + }); + + const overwriteWarningProps = await waitFor(() => { + const dialogProps = ( + mockBulkRenameDialogs.mock.calls as unknown as Array< + [{ showOverwriteWarning: boolean; onContinueOverwriteWarning: () => void }] + > + ) + .map(([props]) => props) + .find((props) => props.showOverwriteWarning); + + expect(dialogProps).toBeDefined(); + return dialogProps; + }); + + await act(async () => { + overwriteWarningProps?.onContinueOverwriteWarning(); + }); + + await waitFor(() => { + expect(mockUpdateWorkerNames).toHaveBeenCalledTimes(1); + }); + + expect(onWorkerNameUpdated).toHaveBeenNthCalledWith(1, "miner-alpha", "worker-1"); + expect(onWorkerNameUpdated).toHaveBeenNthCalledWith(2, "miner-beta", "worker-2"); + }); + + it("sorts default name previews with the backend miner-name fallback", async () => { + mockUpdateWorkerNames.mockResolvedValue({ + updatedCount: 2, + unchangedCount: 0, + failedCount: 0, + }); + + const onWorkerNameUpdated = vi.fn(); + + renderModal({ + selectedMinerIds: ["miner-z", "miner-a"], + totalCount: 2, + miners: { + "miner-a": makeMiner("miner-a", "", "pool-a", { + manufacturer: "Bitmain", + model: "S19", + }), + "miner-z": makeMiner("miner-z", "", "pool-z", { + manufacturer: "Avalon", + model: "1246", + }), + }, + minerIds: ["miner-a", "miner-z"], + onWorkerNameUpdated, + }); + + await waitFor(() => { + expect(getLatestPreviewPanelProps().previewRows).toEqual([ + { + currentName: "pool-z", + newName: "worker-1", + }, + { + currentName: "pool-a", + newName: "worker-2", + }, + ]); + }); + + await act(async () => { + getLatestFullScreenModalProps().buttons[0]?.onClick(); + }); + + const overwriteWarningProps = await waitFor(() => { + const dialogProps = ( + mockBulkRenameDialogs.mock.calls as unknown as Array< + [{ showOverwriteWarning: boolean; onContinueOverwriteWarning: () => void }] + > + ) + .map(([props]) => props) + .find((props) => props.showOverwriteWarning); + + expect(dialogProps).toBeDefined(); + return dialogProps; + }); + + await act(async () => { + overwriteWarningProps?.onContinueOverwriteWarning(); + }); + + await waitFor(() => { + expect(mockUpdateWorkerNames).toHaveBeenCalledTimes(1); + }); + + expect(onWorkerNameUpdated).toHaveBeenNthCalledWith(1, "miner-z", "worker-1"); + expect(onWorkerNameUpdated).toHaveBeenNthCalledWith(2, "miner-a", "worker-2"); + }); + + it("sorts blank worker names after populated values in worker-name previews", async () => { + mockUpdateWorkerNames.mockResolvedValue({ + updatedCount: 3, + unchangedCount: 0, + failedCount: 0, + }); + + const onWorkerNameUpdated = vi.fn(); + + renderModal({ + currentSort: create(SortConfigSchema, { + field: SortField.WORKER_NAME, + direction: SortDirection.ASC, + }), + selectedMinerIds: ["miner-blank", "miner-space", "miner-alpha"], + totalCount: 3, + miners: { + "miner-alpha": makeMiner("miner-alpha", "Miner Alpha", "alpha"), + "miner-blank": makeMiner("miner-blank", "Miner Blank", ""), + "miner-space": makeMiner("miner-space", "Miner Space", " "), + }, + minerIds: ["miner-blank", "miner-space", "miner-alpha"], + onWorkerNameUpdated, + }); + + await waitFor(() => { + expect(getLatestPreviewPanelProps().previewRows).toEqual([ + { + currentName: "alpha", + newName: "worker-1", + }, + { + currentName: "", + newName: "worker-2", + }, + { + currentName: " ", + newName: "worker-3", + }, + ]); + }); + + await act(async () => { + getLatestFullScreenModalProps().buttons[0]?.onClick(); + }); + + const overwriteWarningProps = await waitFor(() => { + const dialogProps = ( + mockBulkRenameDialogs.mock.calls as unknown as Array< + [{ showOverwriteWarning: boolean; onContinueOverwriteWarning: () => void }] + > + ) + .map(([props]) => props) + .find((props) => props.showOverwriteWarning); + + expect(dialogProps).toBeDefined(); + return dialogProps; + }); + + await act(async () => { + overwriteWarningProps?.onContinueOverwriteWarning(); + }); + + await waitFor(() => { + expect(mockUpdateWorkerNames).toHaveBeenCalledTimes(1); + }); + + expect(onWorkerNameUpdated).toHaveBeenNthCalledWith(1, "miner-alpha", "worker-1"); + expect(onWorkerNameUpdated).toHaveBeenNthCalledWith(2, "miner-blank", "worker-2"); + expect(onWorkerNameUpdated).toHaveBeenNthCalledWith(3, "miner-space", "worker-3"); + }); + + it("uses the same default sort for the preview head and tail queries when no sort is provided", async () => { + mockListMinerStateSnapshots.mockResolvedValue({ + miners: [makeMiner("miner-1", "Miner 1"), makeMiner("miner-2", "Miner 2"), makeMiner("miner-3", "Miner 3")], + }); + + renderModal({ + selectedMinerIds: ["miner-1"], + selectionMode: "all", + totalCount: 8, + miners: {}, + minerIds: [], + }); + + await waitFor(() => { + expect(mockListMinerStateSnapshots).toHaveBeenCalledTimes(2); + }); + + const [headCall, tailCall] = mockListMinerStateSnapshots.mock.calls as unknown as Array< + [{ sort: Array<{ field: SortField; direction: SortDirection }> }] + >; + + expect(headCall?.[0].sort).toEqual([ + expect.objectContaining({ + field: SortField.NAME, + direction: SortDirection.ASC, + }), + ]); + expect(tailCall?.[0].sort).toEqual([ + expect.objectContaining({ + field: SortField.NAME, + direction: SortDirection.DESC, + }), + ]); + }); + + it("submits worker-name updates with the preview sort when no current sort is provided", async () => { + mockUpdateWorkerNames.mockResolvedValue({ + updatedCount: 3, + unchangedCount: 0, + failedCount: 0, + }); + + renderModal(); + + await waitFor(() => { + expect(mockFullScreenTwoPaneModal).toHaveBeenCalled(); + }); + + await act(async () => { + getLatestFullScreenModalProps().buttons[0]?.onClick(); + }); + + await waitFor(() => { + expect(mockUpdateWorkerNames).toHaveBeenCalledTimes(1); + }); + + const updateWorkerNamesCalls = mockUpdateWorkerNames.mock.calls as unknown as Array< + [unknown, unknown, string, string, { field: SortField; direction: SortDirection }] + >; + + expect(updateWorkerNamesCalls[0]?.[4]).toEqual( + expect.objectContaining({ + field: SortField.NAME, + direction: SortDirection.ASC, + }), + ); + }); + + it("keeps the overwrite warning when a capability-filtered all-selection extends beyond loaded miners", async () => { + renderModal({ + selectedMinerIds: ["miner-1", "miner-2", "miner-3", "miner-4"], + selectionMode: "subset", + originalSelectionMode: "all", + totalCount: 4, + miners: { + "miner-1": makeMiner("miner-1", "Miner 1"), + }, + minerIds: ["miner-1"], + }); + + await waitFor(() => { + expect(mockFullScreenTwoPaneModal).toHaveBeenCalled(); + }); + + getLatestFullScreenModalProps().buttons[0]?.onClick(); + + await waitFor(() => { + expect( + ( + mockBulkRenameDialogs.mock.calls as unknown as Array< + [{ showOverwriteWarning: boolean; onContinueOverwriteWarning: () => void }] + > + ) + .map(([props]) => props) + .find((props) => props.showOverwriteWarning), + ).toBeDefined(); + }); + + expect(mockUpdateWorkerNames).not.toHaveBeenCalled(); + }); + + it("skips optimistic visible updates when the capability-filtered target is not fully loaded locally", async () => { + mockUpdateWorkerNames.mockResolvedValue({ + updatedCount: 4, + unchangedCount: 0, + failedCount: 0, + }); + + const onWorkerNameUpdated = vi.fn(); + + renderModal({ + selectedMinerIds: ["miner-1", "miner-2", "miner-3", "miner-4"], + selectionMode: "subset", + originalSelectionMode: "all", + totalCount: 4, + miners: { + "miner-1": makeMiner("miner-1", "Miner 1"), + "miner-2": makeMiner("miner-2", "Miner 2"), + }, + minerIds: ["miner-1", "miner-2"], + onWorkerNameUpdated, + }); + + await waitFor(() => { + expect(mockFullScreenTwoPaneModal).toHaveBeenCalled(); + }); + + await act(async () => { + getLatestFullScreenModalProps().buttons[0]?.onClick(); + }); + + const latestDialogProps = await waitFor(() => { + const dialogProps = ( + mockBulkRenameDialogs.mock.calls as unknown as Array< + [{ showOverwriteWarning: boolean; onContinueOverwriteWarning: () => void }] + > + ) + .map(([props]) => props) + .find((props) => props.showOverwriteWarning); + + expect(dialogProps).toBeDefined(); + return dialogProps; + }); + + await act(async () => { + latestDialogProps?.onContinueOverwriteWarning(); + }); + + await waitFor(() => { + expect(mockUpdateWorkerNames).toHaveBeenCalledTimes(1); + }); + + expect(onWorkerNameUpdated).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkWorkerNameModal.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkWorkerNameModal.tsx new file mode 100644 index 000000000..fca114dc6 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkWorkerNameModal.tsx @@ -0,0 +1,1002 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { create } from "@bufbuild/protobuf"; +import { type DragEndEvent } from "@dnd-kit/core"; + +import { + bulkRenameModes, + type BulkRenamePreferences, + type BulkRenamePreviewMiner, + type BulkRenamePropertyId, + type BulkRenamePropertyOptions, + reorderBulkRenameProperties, + shouldWarnAboutBulkRenameDuplicates, + updateBulkRenameProperty, +} from "./bulkRenameDefinitions"; +import BulkRenameDialogs from "./BulkRenameDialogs"; +import BulkRenameOptionModals from "./BulkRenameOptionModals"; +import { + buildBulkRenameConfig, + buildBulkRenamePropertyPreview, + evaluateBulkRenamePreviewName, + findBulkRenamePropertyPreviewMinerIndex, + getMinerPreviewName, + mapSnapshotsToBulkRenamePreviewMiners, + mapSnapshotToBulkRenamePreviewMiner, + shouldShowBulkRenameNoChangesWarning, + takePreviewMiners, +} from "./bulkRenamePreview"; +import BulkRenamePreviewPanel, { type PreviewRow } from "./BulkRenamePreviewPanel"; +import BulkRenamePropertyForm from "./BulkRenamePropertyForm"; +import { settingsActions } from "./constants"; +import { + type CustomPropertyOptionsValues, + type FixedValueOptionsValues, + type QualifierOptionsValues, +} from "./RenameOptionsModals/types"; +import { waitForWorkerNameBatchResult, type WorkerNameBatchResult } from "./waitForWorkerNameBatchResult"; +import { fleetManagementClient } from "@/protoFleet/api/clients"; +import { DeviceIdentifierListSchema } from "@/protoFleet/api/generated/common/v1/device_selector_pb"; +import { + type SortConfig, + SortConfigSchema, + SortDirection, + SortField, +} from "@/protoFleet/api/generated/common/v1/sort_pb"; +import { + type DeviceSelector, + DeviceSelectorSchema, + type MinerListFilter, + type MinerStateSnapshot, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { useMinerCommand } from "@/protoFleet/api/useMinerCommand"; +import useUpdateWorkerNames from "@/protoFleet/api/useUpdateWorkerNames"; +import FullScreenTwoPaneModal from "@/protoFleet/components/FullScreenTwoPaneModal"; +import { useBatchOperations } from "@/protoFleet/features/fleetManagement/hooks/useBatchOperations"; +import { + applyFleetSelectablePairingStatuses, + isFleetSelectablePairingStatus, +} from "@/protoFleet/features/fleetManagement/utils/fleetVisiblePairingFilter"; +import { useAuthErrors, useBulkWorkerNamePreferences, useSetBulkWorkerNamePreferences } from "@/protoFleet/store"; +import { Info } from "@/shared/assets/icons"; +import { variants } from "@/shared/components/Button"; +import Callout from "@/shared/components/Callout"; +import { type SelectionMode } from "@/shared/components/List"; +import { pushToast, removeToast, STATUSES as TOAST_STATUSES, updateToast } from "@/shared/features/toaster"; +import { useWindowDimensions } from "@/shared/hooks/useWindowDimensions"; +import { getLatestMeasurementWithData } from "@/shared/utils/measurementUtils"; + +interface BulkWorkerNameModalProps { + open: boolean; + selectedMinerIds: string[]; + selectionMode: SelectionMode; + originalSelectionMode?: SelectionMode; + totalCount?: number; + currentFilter?: MinerListFilter; + currentSort?: SortConfig; + miners: Record; + minerIds: string[]; + onRefetchMiners?: () => void; + onWorkerNameUpdated?: (deviceIdentifier: string, workerName: string) => void; + getWorkerNameCredentials?: () => { username: string; password: string } | undefined; + onDismiss: () => void; +} + +const duplicateNamesDialogBody = + "Some miners may have duplicate worker names. Proceeding may impact accuracy in pool dashboards. Do you want to continue anyway?"; +const noChangesDialogBody = + "You can continue to retain your existing worker names, or keep editing. Do you want to continue anyway?"; +const overwriteDialogBody = + "This will replace existing worker names for the selected miners in Fleet. The apply action will also update current pool settings to use the new worker names."; +const emptyOptionsPreview = { + previewName: "", + highlightedText: undefined, + highlightStartIndex: undefined, +} as const; +const previewSortCollator = new Intl.Collator(undefined, { + numeric: true, + sensitivity: "base", +}); + +function getSelectionCount(selectionMode: SelectionMode, selectedMinerIds: string[], totalCount?: number): number { + if (selectionMode === "all") { + return totalCount ?? selectedMinerIds.length; + } + + return selectedMinerIds.length; +} + +function computePreviewNames(preferences: BulkRenamePreferences, previewMiners: BulkRenamePreviewMiner[]): string[] { + const config = buildBulkRenameConfig(preferences); + return previewMiners.map((miner) => evaluateBulkRenamePreviewName(config, miner, miner.counterIndex)); +} + +function buildVisibleWorkerNamesByDeviceIdentifier( + preferences: BulkRenamePreferences, + previewMiners: BulkRenamePreviewMiner[], +): Record { + const config = buildBulkRenameConfig(preferences); + + return Object.fromEntries( + previewMiners + .map( + (miner) => [miner.deviceIdentifier, evaluateBulkRenamePreviewName(config, miner, miner.counterIndex)] as const, + ) + .filter(([, workerName]) => workerName.trim() !== ""), + ); +} + +function getPreviewRows(previewMiners: BulkRenamePreviewMiner[], previewNames: string[]): PreviewRow[] { + return previewMiners.map((miner, index) => ({ + currentName: miner.currentName, + newName: previewNames[index] ?? "", + })); +} + +function buildOptionsPreviewPreferences( + preferences: BulkRenamePreferences, + propertyId: BulkRenamePropertyId, + options: BulkRenamePropertyOptions | null, +): BulkRenamePreferences { + return updateBulkRenameProperty(preferences, propertyId, (property) => ({ + ...property, + enabled: true, + options: options ?? property.options, + })); +} + +function formatMinerCount(count: number): string { + return `${count} miner${count === 1 ? "" : "s"}`; +} + +function getBulkWorkerNameLoadingMessage(selectionCount: number): string { + return selectionCount === 1 ? "Updating worker name" : "Updating worker names"; +} + +function getBulkWorkerNameSuccessMessage(updatedCount: number, unchangedCount: number): string { + if (unchangedCount === 0) { + return `Updated ${formatMinerCount(updatedCount)}`; + } + + if (updatedCount === 0) { + return `${formatMinerCount(unchangedCount)} unchanged`; + } + + return `Updated ${formatMinerCount(updatedCount)}; ${formatMinerCount(unchangedCount)} unchanged`; +} + +function getBulkWorkerNameFailureMessage(failedCount: number): string { + return `Failed to update worker names for ${formatMinerCount(failedCount)}`; +} + +function getBulkWorkerNameRequestFailureMessage(selectionCount: number): string { + return selectionCount === 1 ? "Failed to update worker name" : "Failed to update worker names"; +} + +function getVisibleSuccessfulWorkerNameDeviceIds( + submittedWorkerNamesByDeviceIdentifier: Record, + failedCount: number, +): string[] { + if (failedCount > 0) { + return []; + } + + return Object.keys(submittedWorkerNamesByDeviceIdentifier); +} + +function getLatestMeasurementValue( + measurements: + | MinerStateSnapshot["powerUsage"] + | MinerStateSnapshot["temperature"] + | MinerStateSnapshot["hashrate"] + | MinerStateSnapshot["efficiency"], +): number | undefined { + return getLatestMeasurementWithData(measurements)?.value; +} + +function compareSnapshotMetric( + leftValue: number | undefined, + rightValue: number | undefined, + direction: SortDirection, +): number { + if (leftValue === undefined && rightValue === undefined) { + return 0; + } + + if (leftValue === undefined) { + return 1; + } + + if (rightValue === undefined) { + return -1; + } + + const difference = leftValue - rightValue; + return direction === SortDirection.DESC ? -difference : difference; +} + +function compareSnapshotText(leftValue: string, rightValue: string, direction: SortDirection): number { + const comparison = previewSortCollator.compare(leftValue, rightValue); + return direction === SortDirection.DESC ? -comparison : comparison; +} + +function normalizeNullableSnapshotText(value: string): string | null { + const trimmed = value.trim(); + return trimmed === "" ? null : trimmed; +} + +function compareNullableSnapshotText(leftValue: string, rightValue: string, direction: SortDirection): number { + const leftText = normalizeNullableSnapshotText(leftValue); + const rightText = normalizeNullableSnapshotText(rightValue); + + if (leftText === null && rightText === null) { + return 0; + } + + if (leftText === null) { + return 1; + } + + if (rightText === null) { + return -1; + } + + return compareSnapshotText(leftText, rightText, direction); +} + +function compareMinerSnapshots(left: MinerStateSnapshot, right: MinerStateSnapshot, previewSort: SortConfig): number { + const direction = previewSort.direction; + + switch (previewSort.field) { + case SortField.WORKER_NAME: + return compareNullableSnapshotText(left.workerName, right.workerName, direction); + case SortField.IP_ADDRESS: + return compareSnapshotText(left.ipAddress, right.ipAddress, direction); + case SortField.MAC_ADDRESS: + return compareSnapshotText(left.macAddress, right.macAddress, direction); + case SortField.MODEL: + return compareSnapshotText(left.model, right.model, direction); + case SortField.HASHRATE: + return compareSnapshotMetric( + getLatestMeasurementValue(left.hashrate), + getLatestMeasurementValue(right.hashrate), + direction, + ); + case SortField.TEMPERATURE: + return compareSnapshotMetric( + getLatestMeasurementValue(left.temperature), + getLatestMeasurementValue(right.temperature), + direction, + ); + case SortField.POWER: + return compareSnapshotMetric( + getLatestMeasurementValue(left.powerUsage), + getLatestMeasurementValue(right.powerUsage), + direction, + ); + case SortField.EFFICIENCY: + return compareSnapshotMetric( + getLatestMeasurementValue(left.efficiency), + getLatestMeasurementValue(right.efficiency), + direction, + ); + case SortField.FIRMWARE: + return compareSnapshotText(left.firmwareVersion, right.firmwareVersion, direction); + case SortField.UNSPECIFIED: + case SortField.NAME: + default: + return compareSnapshotText(getMinerPreviewName(left), getMinerPreviewName(right), direction); + } +} + +function sortMinerSnapshotsByPreviewSort( + snapshots: MinerStateSnapshot[], + previewSort: SortConfig, +): MinerStateSnapshot[] { + return snapshots + .map((snapshot, index) => ({ snapshot, index })) + .sort((left, right) => { + const comparison = compareMinerSnapshots(left.snapshot, right.snapshot, previewSort); + return comparison !== 0 ? comparison : left.index - right.index; + }) + .map(({ snapshot }) => snapshot); +} + +type WorkerNameUpdateCompletion = { + updatedCount: number; + unchangedCount: number; + failedCount: number; + successfulDeviceIds?: string[]; + submittedWorkerNamesByDeviceIdentifier?: Record; +}; + +function createBulkWorkerNameDeviceSelector( + selectionMode: SelectionMode, + currentFilter: MinerListFilter | undefined, + selectedMinerIds: string[], +): DeviceSelector { + const selectionType = + selectionMode === "all" + ? { + case: "allDevices" as const, + value: applyFleetSelectablePairingStatuses(currentFilter), + } + : { + case: "includeDevices" as const, + value: create(DeviceIdentifierListSchema, { + deviceIdentifiers: selectedMinerIds, + }), + }; + + return create(DeviceSelectorSchema, { selectionType }); +} + +const BulkWorkerNameModal = ({ + open, + selectedMinerIds, + selectionMode, + originalSelectionMode, + totalCount, + currentFilter, + currentSort, + miners: minersById, + minerIds, + onRefetchMiners, + onWorkerNameUpdated, + getWorkerNameCredentials, + onDismiss, +}: BulkWorkerNameModalProps) => { + const { startBatchOperation, completeBatchOperation } = useBatchOperations(); + const preferences = useBulkWorkerNamePreferences(); + const setBulkWorkerNamePreferences = useSetBulkWorkerNamePreferences(); + const { handleAuthErrors } = useAuthErrors(); + const { streamCommandBatchUpdates } = useMinerCommand(); + const { updateWorkerNames } = useUpdateWorkerNames(); + const { isPhone, isTablet } = useWindowDimensions(); + + const [previewMiners, setPreviewMiners] = useState([]); + const [previewNames, setPreviewNames] = useState([]); + const [showPreviewEllipsis, setShowPreviewEllipsis] = useState(false); + const [isLoadingPreview, setIsLoadingPreview] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [activeOptionsPropertyId, setActiveOptionsPropertyId] = useState(null); + const [activeOptionsDraft, setActiveOptionsDraft] = useState(null); + const [showDuplicateNamesWarning, setShowDuplicateNamesWarning] = useState(false); + const [showNoChangesWarning, setShowNoChangesWarning] = useState(false); + const [showOverwriteWarning, setShowOverwriteWarning] = useState(false); + const preferencesRef = useRef(preferences); + const previewMinersRef = useRef(previewMiners); + + const selectionCount = useMemo( + () => getSelectionCount(selectionMode, selectedMinerIds, totalCount), + [selectionMode, selectedMinerIds, totalCount], + ); + const overwriteFallbackSelectionMode = originalSelectionMode ?? selectionMode; + const previewSampleSize = useMemo(() => (isPhone || isTablet ? 1 : 6), [isPhone, isTablet]); + const previewSort = useMemo( + () => + currentSort ?? + create(SortConfigSchema, { + field: SortField.NAME, + direction: SortDirection.ASC, + }), + [currentSort], + ); + const selectedMinerIdSet = useMemo(() => new Set(selectedMinerIds), [selectedMinerIds]); + const localPreviewSnapshots = useMemo(() => { + const snapshots = + selectionMode === "subset" + ? minerIds + .filter((deviceIdentifier) => selectedMinerIdSet.has(deviceIdentifier)) + .map((deviceIdentifier) => minersById[deviceIdentifier]) + .filter((miner): miner is NonNullable => miner !== undefined) + : minerIds + .map((deviceIdentifier) => minersById[deviceIdentifier]) + .filter( + (miner): miner is NonNullable => + miner !== undefined && isFleetSelectablePairingStatus(miner.pairingStatus), + ); + + return sortMinerSnapshotsByPreviewSort(snapshots, previewSort); + }, [minerIds, minersById, previewSort, selectedMinerIdSet, selectionMode]); + const localPreviewMiners = useMemo( + () => mapSnapshotsToBulkRenamePreviewMiners(localPreviewSnapshots, bulkRenameModes.worker), + [localPreviewSnapshots], + ); + + const localValidationMiners = useMemo(() => { + if (selectionMode === "subset") { + return localPreviewMiners.length === selectedMinerIds.length ? localPreviewMiners : null; + } + + return localPreviewMiners.length === selectionCount ? localPreviewMiners : null; + }, [localPreviewMiners, selectedMinerIds.length, selectionCount, selectionMode]); + const canOptimisticallyUpdateVisibleWorkerNames = useMemo( + () => selectionMode === "subset" && localValidationMiners !== null, + [localValidationMiners, selectionMode], + ); + + const applyVisibleWorkerNameUpdates = useCallback( + (successfulDeviceIds: string[], submittedWorkerNamesByDeviceIdentifier: Record) => { + if (!canOptimisticallyUpdateVisibleWorkerNames) { + return; + } + + successfulDeviceIds.forEach((deviceIdentifier) => { + const workerName = submittedWorkerNamesByDeviceIdentifier[deviceIdentifier]; + if (workerName !== undefined) { + onWorkerNameUpdated?.(deviceIdentifier, workerName); + } + }); + }, + [canOptimisticallyUpdateVisibleWorkerNames, onWorkerNameUpdated], + ); + + const finishWorkerNameUpdate = useCallback( + (toastId: number, completion: WorkerNameUpdateCompletion) => { + const { + updatedCount, + unchangedCount, + failedCount, + successfulDeviceIds = [], + submittedWorkerNamesByDeviceIdentifier = {}, + } = completion; + + applyVisibleWorkerNameUpdates(successfulDeviceIds, submittedWorkerNamesByDeviceIdentifier); + onRefetchMiners?.(); + + if (updatedCount > 0 || unchangedCount > 0) { + updateToast(toastId, { + message: getBulkWorkerNameSuccessMessage(updatedCount, unchangedCount), + status: TOAST_STATUSES.success, + }); + } else if (failedCount > 0) { + updateToast(toastId, { + message: getBulkWorkerNameFailureMessage(failedCount), + status: TOAST_STATUSES.error, + }); + } else { + removeToast(toastId); + } + + if (failedCount > 0 && (updatedCount > 0 || unchangedCount > 0)) { + pushToast({ + message: getBulkWorkerNameFailureMessage(failedCount), + status: TOAST_STATUSES.error, + longRunning: true, + }); + } + + onDismiss(); + }, + [applyVisibleWorkerNameUpdates, onDismiss, onRefetchMiners], + ); + + const handleWorkerNameBatchRequestFailure = useCallback( + (toastId: number) => { + updateToast(toastId, { + message: getBulkWorkerNameRequestFailureMessage(selectionCount), + status: TOAST_STATUSES.error, + }); + onRefetchMiners?.(); + onDismiss(); + }, + [onDismiss, onRefetchMiners, selectionCount], + ); + + const loadPreviewMiners = useCallback(async (): Promise<{ + miners: BulkRenamePreviewMiner[]; + showEllipsis: boolean; + }> => { + if (selectionMode === "subset") { + return takePreviewMiners(localPreviewMiners, selectionCount, previewSampleSize); + } + + if (previewSampleSize === 1) { + const filter = applyFleetSelectablePairingStatuses(currentFilter); + const response = await fleetManagementClient.listMinerStateSnapshots({ + pageSize: 1, + filter, + sort: [previewSort], + }); + + return { + miners: mapSnapshotsToBulkRenamePreviewMiners(response.miners, bulkRenameModes.worker), + showEllipsis: false, + }; + } + + if (localValidationMiners !== null) { + return takePreviewMiners(localValidationMiners, selectionCount, previewSampleSize); + } + + const filter = applyFleetSelectablePairingStatuses(currentFilter); + const sort = [previewSort]; + const reverseSort = [ + create(SortConfigSchema, { + field: previewSort.field, + direction: previewSort.direction === SortDirection.DESC ? SortDirection.ASC : SortDirection.DESC, + }), + ]; + + if (selectionCount <= previewSampleSize) { + const response = await fleetManagementClient.listMinerStateSnapshots({ + pageSize: selectionCount, + filter, + sort, + }); + + return { + miners: mapSnapshotsToBulkRenamePreviewMiners(response.miners, bulkRenameModes.worker), + showEllipsis: false, + }; + } + + const headPreviewCount = Math.floor(previewSampleSize / 2); + const tailPreviewCount = previewSampleSize - headPreviewCount; + + const [firstResponse, lastResponse] = await Promise.all([ + fleetManagementClient.listMinerStateSnapshots({ + pageSize: headPreviewCount, + filter, + sort, + }), + fleetManagementClient.listMinerStateSnapshots({ + pageSize: tailPreviewCount, + filter, + sort: reverseSort, + }), + ]); + + return { + miners: [ + ...firstResponse.miners.map((miner, index) => + mapSnapshotToBulkRenamePreviewMiner(miner, index, bulkRenameModes.worker), + ), + ...lastResponse.miners + .map((miner, index) => + mapSnapshotToBulkRenamePreviewMiner(miner, selectionCount - index - 1, bulkRenameModes.worker), + ) + .reverse(), + ], + showEllipsis: true, + }; + }, [ + currentFilter, + localValidationMiners, + localPreviewMiners, + previewSampleSize, + previewSort, + selectionCount, + selectionMode, + ]); + + useEffect(() => { + if (!open) { + setActiveOptionsPropertyId(null); + setActiveOptionsDraft(null); + setShowDuplicateNamesWarning(false); + setShowNoChangesWarning(false); + setShowOverwriteWarning(false); + setPreviewMiners([]); + setPreviewNames([]); + setShowPreviewEllipsis(false); + setIsLoadingPreview(false); + return; + } + + let cancelled = false; + + const load = async () => { + setIsLoadingPreview(true); + + try { + const previewResult = await loadPreviewMiners(); + + if (cancelled) { + return; + } + + setPreviewMiners(previewResult.miners); + setShowPreviewEllipsis(previewResult.showEllipsis); + } catch (error) { + handleAuthErrors({ + error, + onError: () => { + if (cancelled) { + return; + } + setPreviewMiners([]); + setShowPreviewEllipsis(false); + }, + }); + } finally { + if (!cancelled) { + setIsLoadingPreview(false); + } + } + }; + + void load(); + + return () => { + cancelled = true; + }; + }, [handleAuthErrors, loadPreviewMiners, open]); + + useEffect(() => { + preferencesRef.current = preferences; + }, [preferences]); + + useEffect(() => { + previewMinersRef.current = previewMiners; + }, [previewMiners]); + + useEffect(() => { + if (!open) { + return; + } + + setPreviewNames(computePreviewNames(preferencesRef.current, previewMiners)); + }, [open, previewMiners]); + + useEffect(() => { + if (!open) { + return; + } + + const timeoutId = window.setTimeout(() => { + setPreviewNames(computePreviewNames(preferences, previewMinersRef.current)); + }, 500); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [preferences, open]); + + const handleToggleEnabled = useCallback( + (propertyId: BulkRenamePropertyId, enabled: boolean) => { + setBulkWorkerNamePreferences( + updateBulkRenameProperty(preferences, propertyId, (property) => ({ + ...property, + enabled, + })), + ); + }, + [preferences, setBulkWorkerNamePreferences], + ); + + const handleUpdateOptions = useCallback( + ( + propertyId: BulkRenamePropertyId, + options: CustomPropertyOptionsValues | FixedValueOptionsValues | QualifierOptionsValues, + ) => { + setBulkWorkerNamePreferences( + updateBulkRenameProperty(preferences, propertyId, (property) => ({ + ...property, + options, + })), + ); + setActiveOptionsDraft(null); + setActiveOptionsPropertyId(null); + }, + [preferences, setBulkWorkerNamePreferences], + ); + + const handleDismissOptions = useCallback(() => { + setActiveOptionsDraft(null); + setActiveOptionsPropertyId(null); + }, []); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + + if (!over || active.id === over.id) { + return; + } + + setBulkWorkerNamePreferences( + reorderBulkRenameProperties(preferences, active.id as BulkRenamePropertyId, over.id as BulkRenamePropertyId), + ); + }, + [preferences, setBulkWorkerNamePreferences], + ); + + const proceedWithSubmit = useCallback( + async (username: string, password: string) => { + const config = buildBulkRenameConfig(preferences); + if (config.properties.length === 0) { + pushToast({ + message: "Enable at least one worker name property", + status: TOAST_STATUSES.error, + }); + return; + } + + const submittedWorkerNamesByDeviceIdentifier = canOptimisticallyUpdateVisibleWorkerNames + ? buildVisibleWorkerNamesByDeviceIdentifier(preferences, localValidationMiners ?? []) + : {}; + const deviceSelector = createBulkWorkerNameDeviceSelector(selectionMode, currentFilter, selectedMinerIds); + + const toastId = pushToast({ + message: getBulkWorkerNameLoadingMessage(selectionCount), + status: TOAST_STATUSES.loading, + longRunning: true, + }); + + setIsSubmitting(true); + + try { + const response = await updateWorkerNames(deviceSelector, config, username, password, previewSort); + const unchangedCount = Number(response.unchangedCount || 0); + const failedCount = Number(response.failedCount || 0); + + if (response.batchIdentifier) { + startBatchOperation({ + batchIdentifier: response.batchIdentifier, + action: settingsActions.updateWorkerNames, + deviceIdentifiers: selectedMinerIds, + }); + + let batchResult: WorkerNameBatchResult; + try { + batchResult = await waitForWorkerNameBatchResult(streamCommandBatchUpdates, response.batchIdentifier); + } finally { + completeBatchOperation(response.batchIdentifier); + } + + if (batchResult.streamFailed) { + handleWorkerNameBatchRequestFailure(toastId); + return; + } + + finishWorkerNameUpdate(toastId, { + updatedCount: batchResult.successCount, + unchangedCount, + failedCount: failedCount + batchResult.failedCount, + successfulDeviceIds: batchResult.successDeviceIds, + submittedWorkerNamesByDeviceIdentifier, + }); + return; + } + + finishWorkerNameUpdate(toastId, { + updatedCount: Number(response.updatedCount || 0), + unchangedCount, + failedCount, + successfulDeviceIds: getVisibleSuccessfulWorkerNameDeviceIds( + submittedWorkerNamesByDeviceIdentifier, + failedCount, + ), + submittedWorkerNamesByDeviceIdentifier, + }); + } catch { + updateToast(toastId, { + message: getBulkWorkerNameRequestFailureMessage(selectionCount), + status: TOAST_STATUSES.error, + }); + } finally { + setIsSubmitting(false); + } + }, + [ + completeBatchOperation, + finishWorkerNameUpdate, + handleWorkerNameBatchRequestFailure, + currentFilter, + canOptimisticallyUpdateVisibleWorkerNames, + localValidationMiners, + preferences, + previewSort, + selectedMinerIds, + selectionCount, + selectionMode, + startBatchOperation, + streamCommandBatchUpdates, + updateWorkerNames, + ], + ); + + const submitWithAuthenticatedCredentials = useCallback(() => { + const credentials = getWorkerNameCredentials?.(); + + if (!credentials) { + return; + } + + void proceedWithSubmit(credentials.username, credentials.password); + }, [getWorkerNameCredentials, proceedWithSubmit]); + + const noChangeValidationMiners = useMemo(() => { + if (previewMiners.length === selectionCount) { + return previewMiners; + } + + if (localValidationMiners !== null) { + return localValidationMiners; + } + + return null; + }, [localValidationMiners, previewMiners, selectionCount]); + + const shouldShowNoChangesWarning = useMemo( + () => shouldShowBulkRenameNoChangesWarning(preferences, noChangeValidationMiners), + [preferences, noChangeValidationMiners], + ); + + const overwriteValidationMiners = useMemo(() => { + if (previewMiners.length === selectionCount) { + return previewMiners; + } + + return localValidationMiners; + }, [localValidationMiners, previewMiners, selectionCount]); + + const shouldShowOverwriteConfirmation = useMemo(() => { + if (overwriteValidationMiners !== null) { + return overwriteValidationMiners.some((miner) => miner.storedName.trim() !== ""); + } + + return overwriteFallbackSelectionMode === "all"; + }, [overwriteFallbackSelectionMode, overwriteValidationMiners]); + + const handleSubmit = useCallback(() => { + if (shouldShowNoChangesWarning) { + setShowNoChangesWarning(true); + return; + } + + if (shouldWarnAboutBulkRenameDuplicates(selectionCount, preferences, noChangeValidationMiners)) { + setShowDuplicateNamesWarning(true); + return; + } + + if (shouldShowOverwriteConfirmation) { + setShowOverwriteWarning(true); + return; + } + + submitWithAuthenticatedCredentials(); + }, [ + noChangeValidationMiners, + preferences, + selectionCount, + shouldShowNoChangesWarning, + shouldShowOverwriteConfirmation, + submitWithAuthenticatedCredentials, + ]); + + const handleDuplicateNamesContinue = useCallback(() => { + setShowDuplicateNamesWarning(false); + + if (shouldShowOverwriteConfirmation) { + setShowOverwriteWarning(true); + return; + } + + submitWithAuthenticatedCredentials(); + }, [shouldShowOverwriteConfirmation, submitWithAuthenticatedCredentials]); + + const activeOptionsProperty = useMemo( + () => preferences.properties.find((property) => property.id === activeOptionsPropertyId) ?? null, + [activeOptionsPropertyId, preferences.properties], + ); + + const activeOptionsPreview = useMemo(() => { + if (activeOptionsProperty === null || previewMiners.length === 0) { + return emptyOptionsPreview; + } + + const previewPreferences = buildOptionsPreviewPreferences( + preferences, + activeOptionsProperty.id, + activeOptionsDraft, + ); + const previewMinerIndex = findBulkRenamePropertyPreviewMinerIndex( + previewPreferences, + activeOptionsProperty.id, + previewMiners, + ); + + if (previewMinerIndex === null) { + return emptyOptionsPreview; + } + + return buildBulkRenamePropertyPreview( + previewPreferences, + activeOptionsProperty.id, + previewMiners[previewMinerIndex], + previewMiners[previewMinerIndex].counterIndex, + ); + }, [activeOptionsDraft, activeOptionsProperty, preferences, previewMiners]); + + const previewRows = useMemo(() => getPreviewRows(previewMiners, previewNames), [previewMiners, previewNames]); + const isBusy = isSubmitting; + + return ( + <> + void handleSubmit(), + disabled: isBusy || isLoadingPreview, + testId: "bulk-worker-name-save-button", + }, + ]} + primaryPane={ + + setBulkWorkerNamePreferences({ + ...preferences, + separator, + }) + } + leadingContent={ + } + title="Worker names determine how miners appear in pool dashboards." + /> + } + /> + } + secondaryPane={ + + } + /> + + setShowDuplicateNamesWarning(false)} + onContinueDuplicateNames={handleDuplicateNamesContinue} + onDismissNoChanges={() => setShowNoChangesWarning(false)} + onContinueNoChanges={() => { + setShowNoChangesWarning(false); + onDismiss(); + }} + onDismissOverwriteWarning={() => setShowOverwriteWarning(false)} + onContinueOverwriteWarning={() => { + setShowOverwriteWarning(false); + submitWithAuthenticatedCredentials(); + }} + /> + + + + ); +}; + +export default BulkWorkerNameModal; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/CoolingModeModal/CoolingModeModal.stories.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/CoolingModeModal/CoolingModeModal.stories.tsx new file mode 100644 index 000000000..a1deab584 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/CoolingModeModal/CoolingModeModal.stories.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import { action } from "storybook/actions"; +import CoolingModeModal from "./CoolingModeModal"; + +export default { + title: "Proto Fleet/Fleet Management/CoolingModeModal", + component: CoolingModeModal, +}; + +// Story wrapper to handle modal visibility +const StoryWrapper = ({ infoMessage, minerCount = 1 }: { infoMessage?: string; minerCount?: number }) => { + const [show, setShow] = useState(true); + + if (!show) { + return ( +
+ +
+ ); + } + + return ( +
+ {infoMessage && ( +
{infoMessage}
+ )} + { + action("onConfirm")(coolingMode); + setShow(false); + }} + onDismiss={() => { + action("onDismiss")(); + setShow(false); + }} + /> +
+ ); +}; + +// Default story - single miner +export const Default = () => ( + +); + +// Multiple miners +export const MultipleMiners = () => ( + +); + +// Air cooled option explanation +export const AirCooled = () => ( + +); + +// Immersion cooled option explanation +export const ImmersionCooled = () => ( + +); + +// Testing dismiss behavior +export const DismissBehavior = () => ( + +); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/CoolingModeModal/CoolingModeModal.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/CoolingModeModal/CoolingModeModal.tsx new file mode 100644 index 000000000..ee2dde1da --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/CoolingModeModal/CoolingModeModal.tsx @@ -0,0 +1,160 @@ +import React, { useEffect, useState } from "react"; +import clsx from "clsx"; +import { CoolingMode } from "@/protoFleet/api/generated/common/v1/cooling_pb"; +import { Fan } from "@/shared/assets/icons"; +import Immersion from "@/shared/assets/icons/Immersion"; +import { variants } from "@/shared/components/Button"; +import Modal from "@/shared/components/Modal/Modal"; +import SelectRow from "@/shared/components/SelectRow"; +import { selectTypes } from "@/shared/constants"; +import { COOLING_MODES, type CoolingModeOption } from "@/shared/constants/cooling"; + +interface CoolingModeModalProps { + open?: boolean; + minerCount: number; + initialCoolingMode?: CoolingMode; + onConfirm: (coolingMode: CoolingMode) => void; + onDismiss: () => void; +} + +interface CoolingOptionProps { + title: string; + description: string; + icon: React.ReactNode; + isSelected: boolean; +} + +const CoolingOption = ({ title, description, icon, isSelected }: CoolingOptionProps) => ( +
+
+ {icon} +
+
+
{title}
+
{description}
+
+
+); + +interface CoolingModeConfig { + id: CoolingModeOption; + testId: string; + title: string; + description: string; + icon: React.ReactNode; + coolingMode: CoolingMode; +} + +const COOLING_OPTIONS: CoolingModeConfig[] = [ + { + id: COOLING_MODES.air, + testId: "cooling-option-air", + title: "Air cooled", + description: "Your fans will be used to cool your miner", + icon: , + coolingMode: CoolingMode.AIR_COOLED, + }, + { + id: COOLING_MODES.immersion, + testId: "cooling-option-immersion", + title: "Immersion cooled", + description: "Your fans will be disabled", + icon: , + coolingMode: CoolingMode.IMMERSION_COOLED, + }, +]; + +const coolingModeToOption = (mode: CoolingMode | undefined): CoolingModeOption | undefined => { + switch (mode) { + case CoolingMode.AIR_COOLED: + return COOLING_MODES.air; + case CoolingMode.IMMERSION_COOLED: + return COOLING_MODES.immersion; + case CoolingMode.MANUAL: + case CoolingMode.UNSPECIFIED: + default: + return undefined; + } +}; + +const CoolingModeModal = ({ open, minerCount, initialCoolingMode, onConfirm, onDismiss }: CoolingModeModalProps) => { + const [selectedOption, setSelectedOption] = useState( + coolingModeToOption(initialCoolingMode), + ); + + // Sync state with prop when initialCoolingMode changes + useEffect(() => { + setSelectedOption(coolingModeToOption(initialCoolingMode)); + }, [initialCoolingMode]); + + const handleConfirm = () => { + if (!selectedOption) return; + + const selected = COOLING_OPTIONS.find((option) => option.id === selectedOption); + if (selected) { + onConfirm(selected.coolingMode); + } + setSelectedOption(undefined); + }; + + const handleDismiss = () => { + setSelectedOption(undefined); + onDismiss(); + }; + + const handleChange = (id: string) => { + setSelectedOption(id as CoolingModeOption); + }; + + const minerText = minerCount === 1 ? "miner" : "miners"; + const hasSelection = selectedOption !== undefined; + + return ( + +
{`Update the cooling mode for ${minerCount} ${minerText}`}
+
+ {COOLING_OPTIONS.map((option) => ( + + } + type={selectTypes.radio} + /> + ))} +
+
+ ); +}; + +export default CoolingModeModal; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/CoolingModeModal/index.ts b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/CoolingModeModal/index.ts new file mode 100644 index 000000000..25159746b --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/CoolingModeModal/index.ts @@ -0,0 +1 @@ +export { default } from "./CoolingModeModal"; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/FirmwareUpdateModal/FirmwareUpdateModal.stories.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/FirmwareUpdateModal/FirmwareUpdateModal.stories.tsx new file mode 100644 index 000000000..b7c51eb1c --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/FirmwareUpdateModal/FirmwareUpdateModal.stories.tsx @@ -0,0 +1,35 @@ +import { useState } from "react"; +import { action } from "storybook/actions"; +import FirmwareUpdateModal from "./FirmwareUpdateModal"; + +export default { + title: "Proto Fleet/Fleet Management/FirmwareUpdateModal", + component: FirmwareUpdateModal, +}; + +export const Default = () => { + const [open, setOpen] = useState(true); + + return ( + <> + {!open && ( +
+ +
+ )} + { + action("onConfirm")(firmwareFileId); + setOpen(false); + }} + onDismiss={() => { + action("onDismiss")(); + setOpen(false); + }} + /> + + ); +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/FirmwareUpdateModal/FirmwareUpdateModal.test.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/FirmwareUpdateModal/FirmwareUpdateModal.test.tsx new file mode 100644 index 000000000..0a4b02f6f --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/FirmwareUpdateModal/FirmwareUpdateModal.test.tsx @@ -0,0 +1,91 @@ +import type { ReactNode } from "react"; +import { act, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import FirmwareUpdateModal from "./FirmwareUpdateModal"; + +const mockListFirmwareFiles = vi.fn(); +const mockUseFirmwareUpload = vi.fn(); + +vi.mock("@/protoFleet/api/useFirmwareApi", () => ({ + useFirmwareApi: () => ({ + listFirmwareFiles: mockListFirmwareFiles, + }), +})); + +vi.mock("@/protoFleet/components/FirmwareUpload", () => ({ + useFirmwareUpload: () => mockUseFirmwareUpload(), + FileDropZone: vi.fn(() =>
), + FileErrorStatus: vi.fn(({ message }: { message: string }) =>
{message}
), + FileProcessingStatus: vi.fn(() =>
), + FileReadyStatus: vi.fn(() =>
), +})); + +vi.mock("@/shared/components/Modal/Modal", () => ({ + default: vi.fn(({ children, open, title }: { children: ReactNode; open?: boolean; title?: string }) => { + if (open === false) return null; + return ( +
+
{title}
+ {children} +
+ ); + }), +})); + +vi.mock("@/shared/components/ProgressCircular/ProgressCircular", () => ({ + default: vi.fn(() =>
), +})); + +vi.mock("@/shared/features/toaster", () => ({ + pushToast: vi.fn(), + STATUSES: { error: "error" }, +})); + +describe("FirmwareUpdateModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseFirmwareUpload.mockReturnValue({ + state: "idle", + file: null, + firmwareFileId: null, + uploadProgress: 0, + errorMessage: null, + serverConfig: null, + processFile: vi.fn(), + reset: vi.fn(), + retry: vi.fn(), + }); + }); + + it("keeps showing the loading spinner when the file list resolves empty before config loads", async () => { + let resolveFiles: ((files: Array) => void) | undefined; + mockListFirmwareFiles.mockReturnValue( + new Promise>((resolve) => { + resolveFiles = resolve; + }), + ); + + render(); + + expect(screen.getByTestId("progress-circular")).toBeInTheDocument(); + + await act(async () => { + resolveFiles?.([]); + await Promise.resolve(); + }); + + expect(screen.getByTestId("progress-circular")).toBeInTheDocument(); + expect(screen.queryByTestId("file-drop-zone")).not.toBeInTheDocument(); + }); + + it("renders existing files immediately even while config is still loading", async () => { + mockListFirmwareFiles.mockResolvedValue([ + { id: "fw-1", filename: "alpha.swu", size: 1024, uploaded_at: "2025-01-01T00:00:00Z" }, + ]); + + render(); + + expect(await screen.findByText("Select an existing firmware file")).toBeInTheDocument(); + expect(screen.getByText("alpha.swu")).toBeInTheDocument(); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/FirmwareUpdateModal/FirmwareUpdateModal.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/FirmwareUpdateModal/FirmwareUpdateModal.tsx new file mode 100644 index 000000000..52dfd2536 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/FirmwareUpdateModal/FirmwareUpdateModal.tsx @@ -0,0 +1,200 @@ +import { useCallback, useEffect, useState } from "react"; +import clsx from "clsx"; +import type { FirmwareFileInfo } from "@/protoFleet/api/useFirmwareApi"; +import { useFirmwareApi } from "@/protoFleet/api/useFirmwareApi"; +import { + FileDropZone, + FileErrorStatus, + FileProcessingStatus, + FileReadyStatus, + useFirmwareUpload, +} from "@/protoFleet/components/FirmwareUpload"; +import Button, { sizes as buttonSizes, variants } from "@/shared/components/Button"; +import { formatFileSize } from "@/shared/components/FileSizeValue"; +import Modal from "@/shared/components/Modal/Modal"; +import ProgressCircular from "@/shared/components/ProgressCircular/ProgressCircular"; +import { pushToast, STATUSES } from "@/shared/features/toaster"; +import { formatTimestamp, isoToEpochSeconds } from "@/shared/utils/formatTimestamp"; + +interface FirmwareUpdateModalProps { + open?: boolean; + onConfirm: (firmwareFileId: string) => void; + onDismiss: () => void; +} + +const FirmwareUpdateModal = ({ open, onConfirm, onDismiss }: FirmwareUpdateModalProps) => { + const { + state: uploadState, + file: uploadFile, + firmwareFileId: uploadedFileId, + uploadProgress, + errorMessage, + serverConfig, + processFile, + reset, + retry, + } = useFirmwareUpload(!!open); + const { listFirmwareFiles } = useFirmwareApi(); + + const [existingFiles, setExistingFiles] = useState(null); + const [selectedExistingFileId, setSelectedExistingFileId] = useState(null); + const [showUploadZone, setShowUploadZone] = useState(false); + + useEffect(() => { + if (open) { + let cancelled = false; + listFirmwareFiles() + .then((files) => { + if (!cancelled) setExistingFiles(files); + }) + .catch((error) => { + if (cancelled) return; + setExistingFiles([]); + pushToast({ + message: error?.message || "Failed to load firmware files", + status: STATUSES.error, + }); + }); + return () => { + cancelled = true; + }; + } + }, [open, listFirmwareFiles]); + + const handleSelectExistingFile = useCallback( + (fileId: string) => { + if (uploadState !== "idle" && uploadState !== "ready" && uploadState !== "error") return; + reset(); + setSelectedExistingFileId(fileId); + }, + [uploadState, reset], + ); + + const handleUploadFileSelect = useCallback( + (file: File) => { + setSelectedExistingFileId(null); + setShowUploadZone(true); + processFile(file); + }, + [processFile], + ); + + const effectiveFirmwareFileId = selectedExistingFileId ?? uploadedFileId; + const isReady = selectedExistingFileId != null || uploadState === "ready"; + + const handleConfirm = useCallback(() => { + if (effectiveFirmwareFileId) { + onConfirm(effectiveFirmwareFileId); + reset(); + setSelectedExistingFileId(null); + setExistingFiles(null); + setShowUploadZone(false); + } + }, [effectiveFirmwareFileId, onConfirm, reset]); + + const handleDismiss = useCallback(() => { + reset(); + setSelectedExistingFileId(null); + setExistingFiles(null); + setShowUploadZone(false); + onDismiss(); + }, [onDismiss, reset]); + + const isProcessing = uploadState === "hashing" || uploadState === "checking" || uploadState === "uploading"; + const configLoading = uploadState !== "error" && !serverConfig; + const hasExistingFiles = existingFiles != null && existingFiles.length > 0; + const showLoadingSpinner = configLoading && !hasExistingFiles; + + const buttons = isReady ? [{ text: "Continue", variant: variants.primary, onClick: handleConfirm }] : undefined; + + return ( + +
Upload the firmware payload file to update your miners.
+
+ {showLoadingSpinner && ( +
+ +
+ )} + + {hasExistingFiles && ( +
+
Select an existing firmware file
+
+ {existingFiles.map((f) => ( + + ))} +
+ + {serverConfig && ( +
+
+
+ )} + + {uploadState === "error" && errorMessage && } + + {uploadState === "idle" && serverConfig && (!hasExistingFiles || showUploadZone) && ( + + )} + + {isProcessing && uploadFile && ( + + )} + + {uploadState === "ready" && uploadFile && !selectedExistingFileId && ( + + )} +
+ + ); +}; + +export default FirmwareUpdateModal; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/FirmwareUpdateModal/index.ts b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/FirmwareUpdateModal/index.ts new file mode 100644 index 000000000..9cfc3395c --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/FirmwareUpdateModal/index.ts @@ -0,0 +1 @@ +export { default } from "./FirmwareUpdateModal"; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManagePowerModal/ManagePowerModal.stories.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManagePowerModal/ManagePowerModal.stories.tsx new file mode 100644 index 000000000..3756b7140 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManagePowerModal/ManagePowerModal.stories.tsx @@ -0,0 +1,66 @@ +import { useState } from "react"; +import { action } from "storybook/actions"; +import ManagePowerModal from "./ManagePowerModal"; + +export default { + title: "Proto Fleet/Fleet Management/ManagePowerModal", + component: ManagePowerModal, +}; + +// Story wrapper to handle modal visibility +const StoryWrapper = ({ infoMessage }: { infoMessage?: string }) => { + const [show, setShow] = useState(true); + + if (!show) { + return ( +
+ +
+ ); + } + + return ( +
+ {infoMessage && ( +
{infoMessage}
+ )} + { + action("onConfirm")(performanceMode); + setShow(false); + }} + onDismiss={() => { + action("onDismiss")(); + setShow(false); + }} + /> +
+ ); +}; + +// Default story +export const Default = () => ( + +); + +// Maximize power option explanation +export const MaximizePower = () => ( + +); + +// Reduce power option explanation +export const ReducePower = () => ( + +); + +// Testing dismiss behavior +export const DismissBehavior = () => ( + +); + +// Testing confirm behavior +export const ConfirmBehavior = () => ( + +); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManagePowerModal/ManagePowerModal.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManagePowerModal/ManagePowerModal.tsx new file mode 100644 index 000000000..6718ea5d0 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManagePowerModal/ManagePowerModal.tsx @@ -0,0 +1,111 @@ +import { useState } from "react"; +import clsx from "clsx"; +import { PerformanceMode } from "@/protoFleet/api/generated/minercommand/v1/command_pb"; +import { variants } from "@/shared/components/Button"; +import Modal from "@/shared/components/Modal/Modal"; +import SelectRow from "@/shared/components/SelectRow"; +import { selectTypes } from "@/shared/constants"; + +interface ManagePowerModalProps { + open?: boolean; + onConfirm: (performanceMode: PerformanceMode) => void; + onDismiss: () => void; +} + +interface PowerOptionProps { + title: string; + description: string; +} + +const PowerOption = ({ title, description }: PowerOptionProps) => ( +
+
{title}
+
{description}
+
+); + +const POWER_MODES = { + maximize: "maximize", + reduce: "reduce", +} as const; + +type PowerMode = (typeof POWER_MODES)[keyof typeof POWER_MODES]; + +const ManagePowerModal = ({ open, onConfirm, onDismiss }: ManagePowerModalProps) => { + const [selectedOption, setSelectedOption] = useState(undefined); + + const handleConfirm = () => { + if (!selectedOption) return; + + if (selectedOption === POWER_MODES.maximize) { + onConfirm(PerformanceMode.MAXIMUM_HASHRATE); + } else { + onConfirm(PerformanceMode.EFFICIENCY); + } + setSelectedOption(undefined); + }; + + const handleDismiss = () => { + setSelectedOption(undefined); + onDismiss(); + }; + + const handleChange = (id: string) => { + setSelectedOption(id as PowerMode); + }; + + return ( + +
+ + } + type={selectTypes.radio} + /> + + } + type={selectTypes.radio} + /> +
+
+ ); +}; + +export default ManagePowerModal; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManagePowerModal/index.ts b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManagePowerModal/index.ts new file mode 100644 index 000000000..df2e43f39 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManagePowerModal/index.ts @@ -0,0 +1 @@ +export { default } from "./ManagePowerModal"; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManageSecurity/ManageSecurityModal.test.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManageSecurity/ManageSecurityModal.test.tsx new file mode 100644 index 000000000..d146443c0 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManageSecurity/ManageSecurityModal.test.tsx @@ -0,0 +1,354 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import ManageSecurityModal, { type MinerGroup } from "./ManageSecurityModal"; + +vi.mock("@/shared/assets/icons", () => ({ + DismissCircleDark: vi.fn(({ onClick }) => + )), + sizes: { base: "base" }, + variants: { primary: "primary", secondary: "secondary" }, +})); + +vi.mock("@/shared/components/Divider", () => ({ + default: vi.fn(() =>
), +})); + +vi.mock("@/shared/components/Header", () => ({ + default: vi.fn(({ title, icon, buttons }) => ( +
+ {title} +
{icon}
+ {buttons?.map((b: { text: string; onClick: () => void }, i: number) => ( + + ))} +
+ )), +})); + +vi.mock("@/shared/components/PageOverlay", () => ({ + default: vi.fn(({ open, children }) => (open ?
{children}
: null)), +})); + +vi.mock("@/shared/components/Row", () => ({ + default: vi.fn(({ children, prefixIcon, suffixIcon }) => ( +
+
{prefixIcon}
+
{children}
+
{suffixIcon}
+
+ )), +})); + +const makeGroup = (overrides: Partial): MinerGroup => ({ + name: "Proto Rig", + model: "Proto Rig", + manufacturer: "proto", + count: 1, + deviceIdentifiers: ["device-1"], + status: "pending", + ...overrides, +}); + +describe("ManageSecurityModal", () => { + const mockOnUpdateGroup = vi.fn(); + const mockOnDismiss = vi.fn(); + const mockOnDone = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("Visibility", () => { + it("renders when open is true", () => { + render( + , + ); + expect(screen.getByTestId("page-overlay")).toBeInTheDocument(); + }); + + it("does not render when open is false", () => { + render( + , + ); + expect(screen.queryByTestId("page-overlay")).not.toBeInTheDocument(); + }); + }); + + describe("Group sorting", () => { + it("places proto rigs before non-proto groups", () => { + const groups = [ + makeGroup({ name: "Antminer S19", manufacturer: "bitmain", model: "S19" }), + makeGroup({ name: "Proto Rig", manufacturer: "proto", model: "Proto Rig" }), + ]; + render( + , + ); + const rows = screen.getAllByTestId("row-content"); + expect(rows[0]).toHaveTextContent("Proto Rig"); + expect(rows[1]).toHaveTextContent("Antminer S19"); + }); + + it("sorts non-proto groups alphabetically by model", () => { + const groups = [ + makeGroup({ name: "Bitmain S21", manufacturer: "bitmain", model: "S21" }), + makeGroup({ name: "Bitmain S17", manufacturer: "bitmain", model: "S17" }), + makeGroup({ name: "Bitmain S19", manufacturer: "bitmain", model: "S19" }), + ]; + render( + , + ); + const rows = screen.getAllByTestId("row-content"); + expect(rows[0]).toHaveTextContent("Bitmain S17"); + expect(rows[1]).toHaveTextContent("Bitmain S19"); + expect(rows[2]).toHaveTextContent("Bitmain S21"); + }); + }); + + describe("Icons", () => { + it("shows LogoAlt icon for proto rig with pending status", () => { + render( + , + ); + expect(screen.getByTestId("logoalt-icon")).toBeInTheDocument(); + }); + + it("shows Fleet icon for non-proto miner with pending status", () => { + render( + , + ); + expect(screen.getByTestId("fleet-icon")).toBeInTheDocument(); + }); + + it("shows Success icon when status is updated, regardless of manufacturer", () => { + render( + , + ); + expect(screen.getByTestId("success-icon")).toBeInTheDocument(); + expect(screen.queryByTestId("fleet-icon")).not.toBeInTheDocument(); + }); + + it("shows Success icon for proto rig when status is updated", () => { + render( + , + ); + expect(screen.getByTestId("success-icon")).toBeInTheDocument(); + expect(screen.queryByTestId("logoalt-icon")).not.toBeInTheDocument(); + }); + }); + + describe("Action buttons", () => { + it("shows Update button for pending status", () => { + render( + , + ); + expect(screen.getByTestId("action-button")).toHaveTextContent("Update"); + }); + + it("shows Update button for failed status", () => { + render( + , + ); + expect(screen.getByTestId("action-button")).toHaveTextContent("Update"); + }); + + it("shows Update button in loading state for loading status", () => { + render( + , + ); + expect(screen.getByTestId("action-button")).toHaveAttribute("data-loading", "true"); + expect(screen.getByTestId("action-button")).toHaveTextContent("Update"); + }); + + it("shows no action button for updated status", () => { + render( + , + ); + expect(screen.queryByTestId("action-button")).not.toBeInTheDocument(); + }); + }); + + describe("Event handlers", () => { + it("calls onUpdateGroup with the group when Update is clicked", () => { + const group = makeGroup({ status: "pending", name: "Antminer S19", manufacturer: "bitmain" }); + render( + , + ); + fireEvent.click(screen.getByTestId("action-button")); + expect(mockOnUpdateGroup).toHaveBeenCalledWith(group); + }); + + it("calls onDone when the Done header button is clicked", () => { + render( + , + ); + fireEvent.click(screen.getByTestId("header-button-0")); + expect(mockOnDone).toHaveBeenCalled(); + }); + + it("calls onDismiss when the dismiss icon is clicked", () => { + render( + , + ); + fireEvent.click(screen.getByTestId("dismiss-icon")); + expect(mockOnDismiss).toHaveBeenCalled(); + }); + }); + + describe("Miner count display", () => { + it("shows plural 'miners' for count greater than 1", () => { + render( + , + ); + expect(screen.getByText("5 miners")).toBeInTheDocument(); + }); + + it("shows singular 'miner' for count of 1", () => { + render( + , + ); + expect(screen.getByText("1 miner")).toBeInTheDocument(); + }); + }); + + describe("Dividers", () => { + it("renders dividers between groups but not after the last one", () => { + render( + , + ); + // 3 groups → 2 dividers (between groups, not after last) + expect(screen.getAllByTestId("divider")).toHaveLength(2); + }); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManageSecurity/ManageSecurityModal.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManageSecurity/ManageSecurityModal.tsx new file mode 100644 index 000000000..134a1788e --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManageSecurity/ManageSecurityModal.tsx @@ -0,0 +1,127 @@ +import { useMemo } from "react"; +import { minerTypes } from "@/protoFleet/features/fleetManagement/components/MinerList/constants"; +import { DismissCircleDark, Fleet, LogoAlt, Success } from "@/shared/assets/icons"; +import { iconSizes } from "@/shared/assets/icons/constants"; +import Button, { variants } from "@/shared/components/Button"; +import Divider from "@/shared/components/Divider"; +import Header from "@/shared/components/Header"; +import PageOverlay from "@/shared/components/PageOverlay"; +import Row from "@/shared/components/Row"; + +export interface MinerGroup { + name: string; + model: string; + manufacturer: string; + count: number; + deviceIdentifiers: string[]; + status: "pending" | "loading" | "updated" | "failed"; +} + +const getGroupStatusFlags = (status: MinerGroup["status"]) => ({ + isPending: status === "pending", + isLoading: status === "loading", + isFailed: status === "failed", +}); + +interface ManageSecurityModalProps { + open: boolean; + minerGroups: MinerGroup[]; + onUpdateGroup: (group: MinerGroup) => void; + onDismiss: () => void; + onDone: () => void; +} + +const ManageSecurityModal = ({ open, minerGroups, onUpdateGroup, onDismiss, onDone }: ManageSecurityModalProps) => { + const sortedGroups = useMemo(() => { + return [...minerGroups].sort((a, b) => { + // Proto rigs always come first + if (a.manufacturer.toLowerCase() === minerTypes.protoRig && b.manufacturer.toLowerCase() !== minerTypes.protoRig) + return -1; + if (a.manufacturer.toLowerCase() !== minerTypes.protoRig && b.manufacturer.toLowerCase() === minerTypes.protoRig) + return 1; + // Otherwise sort alphabetically by model + return a.model.localeCompare(b.model); + }); + }, [minerGroups]); + + const getIconForGroup = (group: MinerGroup) => { + if (group.status === "updated") { + return ( +
+ +
+ ); + } + if (group.manufacturer.toLowerCase() === minerTypes.protoRig) { + return ; + } + return ; + }; + + const getActionButton = (group: MinerGroup) => { + const { isPending, isLoading, isFailed } = getGroupStatusFlags(group.status); + + if (isPending || isLoading || isFailed) { + return ( + + ); + } + return null; + }; + + return ( + +
+
} + inline + buttons={[ + { + text: "Done", + variant: variants.primary, + onClick: onDone, + }, + ]} + /> + +
+
+

Update the admin login for your miners

+

+ This password will be required to make any changes to pools or miner performance. +

+
+ +
+ {sortedGroups.map((group, index) => ( +
+ + + {group.count} {group.count === 1 ? "miner" : "miners"} + + {getActionButton(group)} +
+ } + divider={false} + > +
{group.name}
+ + {index < sortedGroups.length - 1 && } +
+ ))} +
+
+
+ + ); +}; + +export default ManageSecurityModal; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManageSecurity/UpdateMinerPasswordModal.stories.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManageSecurity/UpdateMinerPasswordModal.stories.tsx new file mode 100644 index 000000000..dedad13c0 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManageSecurity/UpdateMinerPasswordModal.stories.tsx @@ -0,0 +1,64 @@ +import { useState } from "react"; +import { action } from "storybook/actions"; +import UpdateMinerPasswordModal from "./UpdateMinerPasswordModal"; + +export default { + title: "Proto Fleet/Fleet Management/UpdateMinerPasswordModal", + component: UpdateMinerPasswordModal, +}; + +export const Default = () => { + const [open, setOpen] = useState(true); + + return ( + <> + {!open && ( +
+ +
+ )} + { + action("onConfirm")({ currentPassword, newPassword }); + setOpen(false); + }} + onDismiss={() => { + action("onDismiss")(); + setOpen(false); + }} + /> + + ); +}; + +export const WithThirdPartyMiners = () => { + const [open, setOpen] = useState(true); + + return ( + <> + {!open && ( +
+ +
+ )} + { + action("onConfirm")({ currentPassword, newPassword }); + setOpen(false); + }} + onDismiss={() => { + action("onDismiss")(); + setOpen(false); + }} + /> + + ); +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManageSecurity/UpdateMinerPasswordModal.test.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManageSecurity/UpdateMinerPasswordModal.test.tsx new file mode 100644 index 000000000..0e1542fa2 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManageSecurity/UpdateMinerPasswordModal.test.tsx @@ -0,0 +1,582 @@ +import React from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import UpdateMinerPasswordModal from "./UpdateMinerPasswordModal"; + +// Mock the Setup components +vi.mock("@/shared/components/Setup", () => ({ + PasswordStrengthMeter: vi.fn(({ onSetScore, password }) => { + // Simulate password strength scoring + React.useEffect(() => { + if (password) { + const score = password.length >= 12 ? 60 : password.length >= 8 ? 40 : 0; + onSetScore(score); + } + }, [password, onSetScore]); + return
Strength: {password.length >= 12 ? "Strong" : "Weak"}
; + }), + WeakPasswordWarning: vi.fn(({ onReturn, onContinue }) => ( +
+ + +
+ )), +})); + +// Mock Modal component +vi.mock("@/shared/components/Modal/Modal", () => ({ + default: vi.fn(({ open, children, buttons, onDismiss }) => { + if (!open) return null; + return ( +
+ {children} +
+ {buttons?.map((button: { text: string; onClick: () => void; disabled?: boolean }, index: number) => ( + + ))} +
+ +
+ ); + }), +})); + +// Mock Input component +vi.mock("@/shared/components/Input", () => ({ + default: vi.fn(({ id, label, type, onChange, autoFocus }) => ( +
+ + onChange(e.target.value)} autoFocus={autoFocus} data-testid={id} /> +
+ )), +})); + +describe("UpdateMinerPasswordModal", () => { + const mockOnConfirm = vi.fn(); + const mockOnDismiss = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("Rendering", () => { + test("renders modal when open is true", () => { + render( + , + ); + + expect(screen.getByTestId("update-password-modal")).toBeInTheDocument(); + expect(screen.getByTestId("currentPassword")).toBeInTheDocument(); + expect(screen.getByTestId("newPassword")).toBeInTheDocument(); + expect(screen.getByTestId("confirmPassword")).toBeInTheDocument(); + }); + + test("does not render modal when open is false", () => { + render( + , + ); + + expect(screen.queryByTestId("update-password-modal")).not.toBeInTheDocument(); + }); + + test("renders password strength meter for Proto rigs", () => { + render( + , + ); + + const newPasswordInput = screen.getByTestId("newPassword"); + fireEvent.change(newPasswordInput, { target: { value: "TestPassword123" } }); + + expect(screen.getByTestId("password-strength-meter")).toBeInTheDocument(); + }); + + test("does not render password strength meter for third-party miners", () => { + render( + , + ); + + expect(screen.queryByTestId("password-strength-meter")).not.toBeInTheDocument(); + }); + + test("autofocuses the current password input", () => { + render( + , + ); + + const currentPasswordInput = screen.getByTestId("currentPassword"); + expect(currentPasswordInput).toHaveFocus(); + }); + }); + + describe("Validation - Proto Rigs", () => { + test("button is disabled when current password is empty", () => { + render( + , + ); + + const newPasswordInput = screen.getByTestId("newPassword"); + const confirmPasswordInput = screen.getByTestId("confirmPassword"); + const continueButton = screen.getByTestId("modal-button-0"); + + fireEvent.change(newPasswordInput, { target: { value: "NewPassword123" } }); + fireEvent.change(confirmPasswordInput, { target: { value: "NewPassword123" } }); + + expect(continueButton).toBeDisabled(); + expect(mockOnConfirm).not.toHaveBeenCalled(); + }); + + test("button is disabled when new password is empty", () => { + render( + , + ); + + const currentPasswordInput = screen.getByTestId("currentPassword"); + const confirmPasswordInput = screen.getByTestId("confirmPassword"); + const continueButton = screen.getByTestId("modal-button-0"); + + fireEvent.change(currentPasswordInput, { target: { value: "CurrentPassword123" } }); + fireEvent.change(confirmPasswordInput, { target: { value: "NewPassword123" } }); + + expect(continueButton).toBeDisabled(); + expect(mockOnConfirm).not.toHaveBeenCalled(); + }); + + test("button is disabled when confirm password is empty", () => { + render( + , + ); + + const currentPasswordInput = screen.getByTestId("currentPassword"); + const newPasswordInput = screen.getByTestId("newPassword"); + const continueButton = screen.getByTestId("modal-button-0"); + + fireEvent.change(currentPasswordInput, { target: { value: "CurrentPassword123" } }); + fireEvent.change(newPasswordInput, { target: { value: "NewPassword123" } }); + + expect(continueButton).toBeDisabled(); + expect(mockOnConfirm).not.toHaveBeenCalled(); + }); + + test("shows validation error when passwords do not match", () => { + render( + , + ); + + const currentPasswordInput = screen.getByTestId("currentPassword"); + const newPasswordInput = screen.getByTestId("newPassword"); + const confirmPasswordInput = screen.getByTestId("confirmPassword"); + const continueButton = screen.getByTestId("modal-button-0"); + + fireEvent.change(currentPasswordInput, { target: { value: "CurrentPassword123" } }); + fireEvent.change(newPasswordInput, { target: { value: "NewPassword123" } }); + fireEvent.change(confirmPasswordInput, { target: { value: "DifferentPassword123" } }); + fireEvent.click(continueButton); + + expect(screen.getByText("Passwords don't match")).toBeInTheDocument(); + expect(mockOnConfirm).not.toHaveBeenCalled(); + }); + + test("shows validation error when password is too short (Proto rigs only)", () => { + render( + , + ); + + const currentPasswordInput = screen.getByTestId("currentPassword"); + const newPasswordInput = screen.getByTestId("newPassword"); + const confirmPasswordInput = screen.getByTestId("confirmPassword"); + const continueButton = screen.getByTestId("modal-button-0"); + + fireEvent.change(currentPasswordInput, { target: { value: "CurrentPassword123" } }); + fireEvent.change(newPasswordInput, { target: { value: "short" } }); + fireEvent.change(confirmPasswordInput, { target: { value: "short" } }); + fireEvent.click(continueButton); + + expect(screen.getByText("Minimum 8 characters required")).toBeInTheDocument(); + expect(mockOnConfirm).not.toHaveBeenCalled(); + }); + + test("shows weak password warning for Proto rigs with weak password", async () => { + render( + , + ); + + const currentPasswordInput = screen.getByTestId("currentPassword"); + const newPasswordInput = screen.getByTestId("newPassword"); + const confirmPasswordInput = screen.getByTestId("confirmPassword"); + const continueButton = screen.getByTestId("modal-button-0"); + + fireEvent.change(currentPasswordInput, { target: { value: "CurrentPassword123" } }); + fireEvent.change(newPasswordInput, { target: { value: "weakpass" } }); + fireEvent.change(confirmPasswordInput, { target: { value: "weakpass" } }); + + await waitFor(() => { + fireEvent.click(continueButton); + }); + + await waitFor(() => { + expect(screen.getByTestId("weak-password-warning")).toBeInTheDocument(); + }); + expect(mockOnConfirm).not.toHaveBeenCalled(); + }); + + test("calls onConfirm when user continues with weak password", async () => { + render( + , + ); + + const currentPasswordInput = screen.getByTestId("currentPassword"); + const newPasswordInput = screen.getByTestId("newPassword"); + const confirmPasswordInput = screen.getByTestId("confirmPassword"); + const continueButton = screen.getByTestId("modal-button-0"); + + fireEvent.change(currentPasswordInput, { target: { value: "CurrentPassword123" } }); + fireEvent.change(newPasswordInput, { target: { value: "weakpass" } }); + fireEvent.change(confirmPasswordInput, { target: { value: "weakpass" } }); + fireEvent.click(continueButton); + + await waitFor(() => { + expect(screen.getByTestId("weak-password-warning")).toBeInTheDocument(); + }); + + const continueAnywayButton = screen.getByText("Continue anyway"); + fireEvent.click(continueAnywayButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith("CurrentPassword123", "weakpass"); + }); + }); + + test("returns to main modal when user clicks 'Create a stronger password'", async () => { + render( + , + ); + + const currentPasswordInput = screen.getByTestId("currentPassword"); + const newPasswordInput = screen.getByTestId("newPassword"); + const confirmPasswordInput = screen.getByTestId("confirmPassword"); + const continueButton = screen.getByTestId("modal-button-0"); + + fireEvent.change(currentPasswordInput, { target: { value: "CurrentPassword123" } }); + fireEvent.change(newPasswordInput, { target: { value: "weakpass" } }); + fireEvent.change(confirmPasswordInput, { target: { value: "weakpass" } }); + fireEvent.click(continueButton); + + await waitFor(() => { + expect(screen.getByTestId("weak-password-warning")).toBeInTheDocument(); + }); + + const createStrongerButton = screen.getByText("Create a stronger password"); + fireEvent.click(createStrongerButton); + + await waitFor(() => { + expect(screen.getByTestId("update-password-modal")).toBeInTheDocument(); + expect(screen.queryByTestId("weak-password-warning")).not.toBeInTheDocument(); + }); + }); + }); + + describe("Validation - Third-Party Miners", () => { + test("does not validate password length for third-party miners", () => { + render( + , + ); + + const currentPasswordInput = screen.getByTestId("currentPassword"); + const newPasswordInput = screen.getByTestId("newPassword"); + const confirmPasswordInput = screen.getByTestId("confirmPassword"); + const continueButton = screen.getByTestId("modal-button-0"); + + fireEvent.change(currentPasswordInput, { target: { value: "current" } }); + fireEvent.change(newPasswordInput, { target: { value: "abc" } }); + fireEvent.change(confirmPasswordInput, { target: { value: "abc" } }); + fireEvent.click(continueButton); + + expect(mockOnConfirm).toHaveBeenCalledWith("current", "abc"); + }); + + test("does not show weak password warning for third-party miners", () => { + render( + , + ); + + const currentPasswordInput = screen.getByTestId("currentPassword"); + const newPasswordInput = screen.getByTestId("newPassword"); + const confirmPasswordInput = screen.getByTestId("confirmPassword"); + const continueButton = screen.getByTestId("modal-button-0"); + + fireEvent.change(currentPasswordInput, { target: { value: "CurrentPassword123" } }); + fireEvent.change(newPasswordInput, { target: { value: "weak" } }); + fireEvent.change(confirmPasswordInput, { target: { value: "weak" } }); + fireEvent.click(continueButton); + + expect(screen.queryByTestId("weak-password-warning")).not.toBeInTheDocument(); + expect(mockOnConfirm).toHaveBeenCalledWith("CurrentPassword123", "weak"); + }); + }); + + describe("Successful submission", () => { + test("calls onConfirm with correct parameters for Proto rigs with strong password", async () => { + render( + , + ); + + const currentPasswordInput = screen.getByTestId("currentPassword"); + const newPasswordInput = screen.getByTestId("newPassword"); + const confirmPasswordInput = screen.getByTestId("confirmPassword"); + const continueButton = screen.getByTestId("modal-button-0"); + + fireEvent.change(currentPasswordInput, { target: { value: "CurrentPassword123" } }); + fireEvent.change(newPasswordInput, { target: { value: "StrongPassword123456" } }); + fireEvent.change(confirmPasswordInput, { target: { value: "StrongPassword123456" } }); + + await waitFor(() => { + fireEvent.click(continueButton); + }); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith("CurrentPassword123", "StrongPassword123456"); + }); + }); + }); + + describe("Enter key handling", () => { + test("submits form when Enter key is pressed with valid inputs", async () => { + render( + , + ); + + const currentPasswordInput = screen.getByTestId("currentPassword"); + const newPasswordInput = screen.getByTestId("newPassword"); + const confirmPasswordInput = screen.getByTestId("confirmPassword"); + + fireEvent.change(currentPasswordInput, { target: { value: "CurrentPassword123" } }); + fireEvent.change(newPasswordInput, { target: { value: "StrongPassword123456" } }); + fireEvent.change(confirmPasswordInput, { target: { value: "StrongPassword123456" } }); + + await waitFor(() => { + fireEvent.keyDown(confirmPasswordInput, { key: "Enter", code: "Enter" }); + }); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith("CurrentPassword123", "StrongPassword123456"); + }); + }); + + test("does not submit form when Enter key is pressed with empty fields", () => { + render( + , + ); + + const currentPasswordInput = screen.getByTestId("currentPassword"); + + fireEvent.keyDown(currentPasswordInput, { key: "Enter", code: "Enter" }); + + expect(mockOnConfirm).not.toHaveBeenCalled(); + }); + }); + + describe("Form reset", () => { + test("resets form when modal is dismissed and reopened", async () => { + const { rerender } = render( + , + ); + + const currentPasswordInput = screen.getByTestId("currentPassword") as HTMLInputElement; + const newPasswordInput = screen.getByTestId("newPassword") as HTMLInputElement; + + fireEvent.change(currentPasswordInput, { target: { value: "CurrentPassword123" } }); + fireEvent.change(newPasswordInput, { target: { value: "NewPassword123" } }); + + expect(currentPasswordInput.value).toBe("CurrentPassword123"); + expect(newPasswordInput.value).toBe("NewPassword123"); + + // Close modal + rerender( + , + ); + + // Reopen modal + rerender( + , + ); + + const currentPasswordInputAfter = screen.getByTestId("currentPassword") as HTMLInputElement; + const newPasswordInputAfter = screen.getByTestId("newPassword") as HTMLInputElement; + + await waitFor(() => { + expect(currentPasswordInputAfter.value).toBe(""); + expect(newPasswordInputAfter.value).toBe(""); + }); + }); + }); + + describe("Dismiss handling", () => { + test("calls onDismiss when dismiss button is clicked", () => { + render( + , + ); + + const dismissButton = screen.getByTestId("modal-dismiss"); + fireEvent.click(dismissButton); + + expect(mockOnDismiss).toHaveBeenCalled(); + }); + }); + + describe("Button states", () => { + test("disables Continue button when fields are empty", () => { + render( + , + ); + + const continueButton = screen.getByTestId("modal-button-0"); + expect(continueButton).toBeDisabled(); + }); + + test("enables Continue button when all fields are filled", () => { + render( + , + ); + + const currentPasswordInput = screen.getByTestId("currentPassword"); + const newPasswordInput = screen.getByTestId("newPassword"); + const confirmPasswordInput = screen.getByTestId("confirmPassword"); + + fireEvent.change(currentPasswordInput, { target: { value: "CurrentPassword123" } }); + fireEvent.change(newPasswordInput, { target: { value: "NewPassword123" } }); + fireEvent.change(confirmPasswordInput, { target: { value: "NewPassword123" } }); + + const continueButton = screen.getByTestId("modal-button-0"); + expect(continueButton).not.toBeDisabled(); + }); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManageSecurity/UpdateMinerPasswordModal.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManageSecurity/UpdateMinerPasswordModal.tsx new file mode 100644 index 000000000..d37c3c467 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManageSecurity/UpdateMinerPasswordModal.tsx @@ -0,0 +1,170 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { Alert } from "@/shared/assets/icons"; +import { variants } from "@/shared/components/Button"; +import Callout from "@/shared/components/Callout"; +import Input from "@/shared/components/Input"; +import Modal from "@/shared/components/Modal/Modal"; +import { PasswordStrengthMeter, WeakPasswordWarning } from "@/shared/components/Setup"; +import { isPasswordTooShort, isWeakPassword, passwordErrors } from "@/shared/components/Setup/authentication.constants"; + +interface UpdateMinerPasswordModalProps { + open: boolean; + hasThirdPartyMiners: boolean; + onConfirm: (currentPassword: string, newPassword: string) => void; + onDismiss: () => void; +} + +const UpdateMinerPasswordModal = ({ + open, + hasThirdPartyMiners, + onConfirm, + onDismiss, +}: UpdateMinerPasswordModalProps) => { + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [score, setScore] = useState(0); + const [validationError, setValidationError] = useState(""); + const [showWeakPasswordWarning, setShowWeakPasswordWarning] = useState(false); + + // Reset form when modal is dismissed + useEffect(() => { + if (!open) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- Form reset on modal close is intentional + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + setScore(0); + setValidationError(""); + setShowWeakPasswordWarning(false); + } + }, [open]); + + const handleConfirm = useCallback( + (forceWeakPassword: boolean) => { + setValidationError(""); + + if (!currentPassword) { + setValidationError("Current password is required"); + return; + } + + if (!newPassword) { + setValidationError("New password is required"); + return; + } + + if (!confirmPassword) { + setValidationError("Password confirmation is required"); + return; + } + + if (newPassword !== confirmPassword) { + setValidationError(passwordErrors.mismatch); + return; + } + + // Additional validation for Proto rigs only (centralized validation from authentication.constants.ts) + if (!hasThirdPartyMiners) { + if (isPasswordTooShort(newPassword)) { + setValidationError(passwordErrors.tooShort); + return; + } + + if (!forceWeakPassword && isWeakPassword(score)) { + setShowWeakPasswordWarning(true); + return; + } + } + + setShowWeakPasswordWarning(false); + onConfirm(currentPassword, newPassword); + }, + [currentPassword, newPassword, confirmPassword, score, hasThirdPartyMiners, onConfirm], + ); + + const handleDismiss = () => { + setShowWeakPasswordWarning(false); + onDismiss(); + }; + + const canConfirm = currentPassword && newPassword && confirmPassword; + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && canConfirm) { + e.preventDefault(); + handleConfirm(false); + } + }, + [canConfirm, handleConfirm], + ); + + // Conditionally render one modal at a time for proper animations + if (showWeakPasswordWarning) { + return ( + setShowWeakPasswordWarning(false)} onContinue={() => handleConfirm(true)} /> + ); + } + + return ( + handleConfirm(false), + disabled: !canConfirm, + dismissModalOnClick: false, + }, + ]} + divider={false} + className="w-full" + > +
+ This password will be required to make any changes to pools or miner performance. +
+ + {validationError ? ( + } title={validationError} /> + ) : null} + +
+ setCurrentPassword(value)} + autoFocus + /> + +
+ setNewPassword(value)} + /> + {!hasThirdPartyMiners && ( +
+
Password strength
+ +
+ )} +
+ + setConfirmPassword(value)} + /> +
+
+ ); +}; + +export default UpdateMinerPasswordModal; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManageSecurity/index.ts b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManageSecurity/index.ts new file mode 100644 index 000000000..bb7b50405 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManageSecurity/index.ts @@ -0,0 +1,2 @@ +export { default as UpdateMinerPasswordModal } from "./UpdateMinerPasswordModal"; +export { default as ManageSecurityModal, type MinerGroup } from "./ManageSecurityModal"; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/MinerActionsMenu.stories.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/MinerActionsMenu.stories.tsx new file mode 100644 index 000000000..a4a0f67c0 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/MinerActionsMenu.stories.tsx @@ -0,0 +1,23 @@ +import { useState } from "react"; +import { action } from "storybook/actions"; +import MinerActionsMenuComponent from "."; + +export const MinerActionsMenu = () => { + const [selectedMiners] = useState(["miner-1", "miner-2", "miner-3"]); + + return ( +
+ +
+ ); +}; + +export default { + title: "Proto Fleet/Miner Actions Menu", + component: MinerActionsMenuComponent, +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/MinerActionsMenu.test.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/MinerActionsMenu.test.tsx new file mode 100644 index 000000000..905efa732 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/MinerActionsMenu.test.tsx @@ -0,0 +1,668 @@ +import { Fragment, type ReactNode } from "react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { deviceActions, groupActions, performanceActions, settingsActions } from "./constants"; +import MinerActionsMenu from "./MinerActionsMenu"; + +// Use vi.hoisted to properly hoist mock variable declarations +const { + mockAddToGroupModal, + mockAuthenticateFleetModal, + mockBulkActionsWidget, + mockBulkRenameModal, + mockBulkWorkerNameModal, + mockWithCapabilityCheck, + mockPoolSelectionPageWrapper, + mockUseBatchOperations, + mockUseMinerActions, + mockUseWindowDimensions, +} = vi.hoisted(() => { + const mockWithCapabilityCheck = vi.fn(async (_action: string, onProceed: (...args: unknown[]) => void) => { + onProceed(undefined, undefined); + }); + + return { + mockAddToGroupModal: vi.fn(() => null), + mockAuthenticateFleetModal: vi.fn(() => null), + mockBulkActionsWidget: vi.fn( + (props: { + buttonTitle: string; + renderQuickActions?: (onAction: (action: { actionHandler: () => void }) => void) => ReactNode; + }) => ( + <> + {props.renderQuickActions?.((action) => action.actionHandler())} +
{props.buttonTitle}
+ + ), + ), + mockBulkRenameModal: vi.fn(() => null), + mockBulkWorkerNameModal: vi.fn(() => null), + mockWithCapabilityCheck, + mockPoolSelectionPageWrapper: vi.fn( + (_props: { + open?: boolean; + selectedMiners: Array<{ deviceIdentifier: string }>; + selectionMode: string; + poolNeededCount?: number; + userUsername?: string; + userPassword?: string; + onSuccess: (batchIdentifier: string) => void; + onError?: (error: string) => void; + onDismiss: () => void; + }) => null, + ), + mockUseBatchOperations: vi.fn(() => ({ + startBatchOperation: vi.fn(), + completeBatchOperation: vi.fn(), + removeDevicesFromBatch: vi.fn(), + })), + mockUseMinerActions: vi.fn( + (): { + currentAction: string | null; + popoverActions: unknown[]; + handleConfirmation: ReturnType; + handleCancel: ReturnType; + handleMiningPoolSuccess: ReturnType; + handleMiningPoolError: ReturnType; + showPoolSelectionPage: boolean; + poolFilteredDeviceIds?: string[]; + fleetCredentials?: { username: string; password: string }; + showManagePowerModal: boolean; + handleManagePowerConfirm: ReturnType; + handleManagePowerDismiss: ReturnType; + showCoolingModeModal: boolean; + coolingModeCount: number; + currentCoolingMode: unknown; + handleCoolingModeConfirm: ReturnType; + handleCoolingModeDismiss: ReturnType; + showAuthenticateFleetModal: boolean; + authenticationPurpose: string | null; + showUpdatePasswordModal: boolean; + hasThirdPartyMiners: boolean; + handleFleetAuthenticated: ReturnType; + handlePasswordConfirm: ReturnType; + handlePasswordDismiss: ReturnType; + handleAuthDismiss: ReturnType; + withCapabilityCheck: ReturnType; + unsupportedMinersInfo: unknown; + handleUnsupportedMinersContinue: ReturnType; + handleUnsupportedMinersDismiss: ReturnType; + showManageSecurityModal: boolean; + minerGroups: unknown[]; + handleUpdateGroup: ReturnType; + handleSecurityModalClose: ReturnType; + showAddToGroupModal: boolean; + handleAddToGroupDismiss: ReturnType; + displayCount: number; + } => ({ + currentAction: null, + popoverActions: [], + handleConfirmation: vi.fn(), + handleCancel: vi.fn(), + handleMiningPoolSuccess: vi.fn(), + handleMiningPoolError: vi.fn(), + showPoolSelectionPage: false, + poolFilteredDeviceIds: undefined, + fleetCredentials: undefined, + showManagePowerModal: false, + handleManagePowerConfirm: vi.fn(), + handleManagePowerDismiss: vi.fn(), + showCoolingModeModal: false, + coolingModeCount: 0, + currentCoolingMode: undefined, + handleCoolingModeConfirm: vi.fn(), + handleCoolingModeDismiss: vi.fn(), + showAuthenticateFleetModal: false, + authenticationPurpose: null, + showUpdatePasswordModal: false, + hasThirdPartyMiners: false, + handleFleetAuthenticated: vi.fn(), + handlePasswordConfirm: vi.fn(), + handlePasswordDismiss: vi.fn(), + handleAuthDismiss: vi.fn(), + withCapabilityCheck: mockWithCapabilityCheck, + unsupportedMinersInfo: undefined, + handleUnsupportedMinersContinue: vi.fn(), + handleUnsupportedMinersDismiss: vi.fn(), + showManageSecurityModal: false, + minerGroups: [], + handleUpdateGroup: vi.fn(), + handleSecurityModalClose: vi.fn(), + showAddToGroupModal: false, + handleAddToGroupDismiss: vi.fn(), + displayCount: 0, + }), + ), + mockUseWindowDimensions: vi.fn(() => ({ + isPhone: false, + isTablet: false, + })), + }; +}); + +vi.mock("../ActionBar/SettingsWidget/PoolSelectionPage", () => ({ + default: mockPoolSelectionPageWrapper, +})); + +// Mock BulkActionsWidget +vi.mock("../BulkActions", () => ({ + default: mockBulkActionsWidget, + BulkActionsPopover: vi.fn(() => null), +})); + +vi.mock("./BulkRenameModal", () => ({ + default: mockBulkRenameModal, +})); + +vi.mock("./BulkWorkerNameModal", () => ({ + default: mockBulkWorkerNameModal, +})); + +vi.mock("./AddToGroupModal", () => ({ + default: mockAddToGroupModal, +})); + +// Mock CoolingModeModal +vi.mock("./CoolingModeModal", () => ({ + default: vi.fn(() => null), +})); + +// Mock ManagePowerModal +vi.mock("./ManagePowerModal", () => ({ + default: vi.fn(() => null), +})); + +// Mock ManageSecurity +vi.mock("./ManageSecurity", () => ({ + ManageSecurityModal: vi.fn(() => null), + UpdateMinerPasswordModal: vi.fn(() => null), +})); + +// Mock AuthenticateFleetModal +vi.mock("@/protoFleet/features/auth/components/AuthenticateFleetModal", () => ({ + default: mockAuthenticateFleetModal, +})); + +vi.mock("./useMinerActions", () => ({ + useMinerActions: mockUseMinerActions, +})); + +vi.mock("@/protoFleet/features/fleetManagement/hooks/useBatchOperations", () => ({ + useBatchOperations: mockUseBatchOperations, +})); + +// Mock Popover +vi.mock("@/shared/components/Popover", () => ({ + PopoverProvider: ({ children }: { children: ReactNode }) => {children}, +})); + +vi.mock("@/shared/hooks/useWindowDimensions", () => ({ + useWindowDimensions: mockUseWindowDimensions, +})); + +// Helper function to create mock useMinerActions return value +const createMockMinerActionsReturn = ( + currentAction: string | null, + showPoolSelectionPage = false, + fleetCredentials?: { username: string; password: string }, +) => ({ + currentAction, + popoverActions: [], + handleConfirmation: vi.fn(), + handleCancel: vi.fn(), + handleMiningPoolSuccess: vi.fn(), + handleMiningPoolError: vi.fn(), + showPoolSelectionPage, + poolFilteredDeviceIds: undefined, + fleetCredentials, + showManagePowerModal: false, + handleManagePowerConfirm: vi.fn(), + handleManagePowerDismiss: vi.fn(), + showCoolingModeModal: false, + coolingModeCount: 0, + currentCoolingMode: undefined, + handleCoolingModeConfirm: vi.fn(), + handleCoolingModeDismiss: vi.fn(), + showAuthenticateFleetModal: false, + authenticationPurpose: null, + showUpdatePasswordModal: false, + hasThirdPartyMiners: false, + handleFleetAuthenticated: vi.fn(), + handlePasswordConfirm: vi.fn(), + handlePasswordDismiss: vi.fn(), + handleAuthDismiss: vi.fn(), + withCapabilityCheck: mockWithCapabilityCheck, + unsupportedMinersInfo: undefined, + handleUnsupportedMinersContinue: vi.fn(), + handleUnsupportedMinersDismiss: vi.fn(), + showManageSecurityModal: false, + minerGroups: [], + handleUpdateGroup: vi.fn(), + handleSecurityModalClose: vi.fn(), + showAddToGroupModal: false, + handleAddToGroupDismiss: vi.fn(), + displayCount: 2, +}); + +describe("MinerActionsMenu", () => { + test.beforeEach(() => { + vi.clearAllMocks(); + mockUseWindowDimensions.mockReturnValue({ + isPhone: false, + isTablet: false, + }); + }); + + test("renders desktop quick actions and switches overflow trigger copy to More", () => { + const blinkLEDsActionHandler = vi.fn(); + const rebootActionHandler = vi.fn(); + const managePowerActionHandler = vi.fn(); + + mockUseWindowDimensions.mockReturnValue({ + isPhone: false, + isTablet: false, + }); + mockUseMinerActions.mockReturnValueOnce({ + ...createMockMinerActionsReturn(null), + popoverActions: [ + { + action: deviceActions.reboot, + title: "Reboot", + icon: null, + actionHandler: rebootActionHandler, + requiresConfirmation: true, + }, + { + action: deviceActions.blinkLEDs, + title: "Blink LEDs", + icon: null, + actionHandler: blinkLEDsActionHandler, + requiresConfirmation: false, + }, + { + action: performanceActions.managePower, + title: "Manage power", + icon: null, + actionHandler: managePowerActionHandler, + requiresConfirmation: false, + }, + ], + }); + + render( + , + ); + + expect(screen.getByTestId("actions-menu-quick-action-blink-leds")).toHaveTextContent("Blink LEDs"); + expect(screen.getByTestId("actions-menu-quick-action-reboot")).toHaveTextContent("Reboot"); + expect(screen.getByTestId("actions-menu-quick-action-manage-power")).toHaveTextContent("Manage power"); + + fireEvent.click(screen.getByTestId("actions-menu-quick-action-blink-leds")); + fireEvent.click(screen.getByTestId("actions-menu-quick-action-reboot")); + fireEvent.click(screen.getByTestId("actions-menu-quick-action-manage-power")); + + expect(blinkLEDsActionHandler).toHaveBeenCalledTimes(1); + expect(rebootActionHandler).toHaveBeenCalledTimes(1); + expect(managePowerActionHandler).toHaveBeenCalledTimes(1); + + const widgetCalls = mockBulkActionsWidget.mock.calls as unknown as Array<[{ buttonTitle: string }]>; + const widgetCall = widgetCalls[widgetCalls.length - 1]; + expect(widgetCall?.[0].buttonTitle).toBe("More"); + }); + + test("hides quick actions on mobile and keeps the actions trigger copy", () => { + mockUseWindowDimensions.mockReturnValue({ + isPhone: true, + isTablet: false, + }); + mockUseMinerActions.mockReturnValueOnce({ + ...createMockMinerActionsReturn(null), + popoverActions: [ + { + action: deviceActions.blinkLEDs, + title: "Blink LEDs", + icon: null, + actionHandler: vi.fn(), + requiresConfirmation: false, + }, + ], + }); + + render( + , + ); + + expect(screen.queryByTestId("actions-menu-quick-action-blink-leds")).not.toBeInTheDocument(); + const widgetCalls = mockBulkActionsWidget.mock.calls as unknown as Array<[{ buttonTitle: string }]>; + const widgetCall = widgetCalls[widgetCalls.length - 1]; + expect(widgetCall?.[0].buttonTitle).toBe("Actions"); + }); + + test("passes totalCount as poolNeededCount when rendering PoolSelectionPageWrapper", async () => { + const selectedMiners = ["miner-1", "miner-2"]; + const totalCount = 297; + + // Mock the current action to be mining pool settings with authentication complete + mockUseMinerActions.mockReturnValueOnce( + createMockMinerActionsReturn(settingsActions.miningPool, true, { username: "testuser", password: "testpass" }), + ); + + render( + , + ); + + // Wait for component to render + await waitFor(() => { + expect(mockPoolSelectionPageWrapper).toHaveBeenCalled(); + }); + + // Verify PoolSelectionPageWrapper was called with totalCount as poolNeededCount + expect(mockPoolSelectionPageWrapper).toHaveBeenCalled(); + const calls = mockPoolSelectionPageWrapper.mock.calls; + const lastCall = calls[calls.length - 1]; + const props = lastCall[0]; + + expect(props.poolNeededCount).toBe(totalCount); + expect(props.selectionMode).toBe("all"); + expect(props.selectedMiners).toEqual([{ deviceIdentifier: "miner-1" }, { deviceIdentifier: "miner-2" }]); + expect(props.userUsername).toBe("testuser"); + expect(props.userPassword).toBe("testpass"); + }); + + test("renders PoolSelectionPageWrapper with open=false when currentAction is not miningPool", () => { + mockUseMinerActions.mockReturnValueOnce(createMockMinerActionsReturn(null)); + + mockPoolSelectionPageWrapper.mockClear(); + + render( + , + ); + + expect(mockPoolSelectionPageWrapper).toHaveBeenCalled(); + const props = mockPoolSelectionPageWrapper.mock.calls[0][0]; + expect(props.open).toBe(false); + }); + + test("injects update worker names after pools and rename before add to group", () => { + mockUseWindowDimensions.mockReturnValue({ + isPhone: false, + isTablet: false, + }); + mockUseMinerActions.mockReturnValueOnce({ + ...createMockMinerActionsReturn(null), + popoverActions: [ + { + action: settingsActions.miningPool, + title: "Edit pool", + icon: null, + actionHandler: vi.fn(), + requiresConfirmation: false, + }, + { + action: settingsActions.coolingMode, + title: "Change cooling mode", + icon: null, + actionHandler: vi.fn(), + requiresConfirmation: false, + showGroupDivider: true, + }, + { + action: groupActions.addToGroup, + title: "Add to group", + icon: null, + actionHandler: vi.fn(), + requiresConfirmation: false, + showGroupDivider: true, + }, + { + action: settingsActions.security, + title: "Manage security", + icon: null, + actionHandler: vi.fn(), + requiresConfirmation: false, + }, + ], + }); + + mockBulkActionsWidget.mockClear(); + + render( + , + ); + + const widgetCalls = mockBulkActionsWidget.mock.calls as unknown as Array< + [{ actions: Array<{ action: string; showGroupDivider?: boolean }> }] + >; + const widgetCall = widgetCalls[0]; + expect(widgetCall).toBeDefined(); + + if (widgetCall === undefined) { + throw new Error("BulkActionsWidget was not called with props"); + } + + const actions = widgetCall[0].actions; + + expect(actions.map((action: { action: string }) => action.action)).toEqual([ + settingsActions.miningPool, + settingsActions.updateWorkerNames, + settingsActions.coolingMode, + settingsActions.rename, + groupActions.addToGroup, + settingsActions.security, + ]); + expect(actions[2].showGroupDivider).toBe(true); + expect(actions[3].showGroupDivider).toBeUndefined(); + expect(actions[4].showGroupDivider).toBe(true); + }); + + test("requests credentials before opening update worker names modal", async () => { + mockUseWindowDimensions.mockReturnValue({ + isPhone: false, + isTablet: false, + }); + mockUseMinerActions.mockReturnValueOnce({ + ...createMockMinerActionsReturn(null), + popoverActions: [ + { + action: settingsActions.miningPool, + title: "Edit pool", + icon: null, + actionHandler: vi.fn(), + requiresConfirmation: false, + }, + { + action: groupActions.addToGroup, + title: "Add to group", + icon: null, + actionHandler: vi.fn(), + requiresConfirmation: false, + }, + ], + }); + + mockBulkActionsWidget.mockClear(); + mockAuthenticateFleetModal.mockClear(); + mockBulkWorkerNameModal.mockClear(); + + render( + , + ); + + const widgetCalls = mockBulkActionsWidget.mock.calls as unknown as Array< + [{ actions: Array<{ action: string; actionHandler: () => void }> }] + >; + const authenticateCalls = mockAuthenticateFleetModal.mock.calls as unknown as Array< + [ + { + purpose?: string; + open: boolean; + onAuthenticated: (username: string, password: string) => void; + }, + ] + >; + const bulkWorkerNameModalCalls = mockBulkWorkerNameModal.mock.calls as unknown as Array< + [ + { + open: boolean; + getWorkerNameCredentials?: () => { username: string; password: string } | undefined; + }, + ] + >; + const updateWorkerNamesAction = widgetCalls[0]?.[0].actions.find( + (action) => action.action === settingsActions.updateWorkerNames, + ); + + expect(updateWorkerNamesAction).toBeDefined(); + + await act(async () => { + updateWorkerNamesAction?.actionHandler(); + }); + + await waitFor(() => { + expect(mockWithCapabilityCheck).toHaveBeenCalledWith(settingsActions.updateWorkerNames, expect.any(Function)); + expect(authenticateCalls.some(([props]) => props.purpose === "workerNames" && props.open)).toBe(true); + }); + + const latestHiddenWorkerNameModalProps = bulkWorkerNameModalCalls[bulkWorkerNameModalCalls.length - 1]?.[0]; + expect(latestHiddenWorkerNameModalProps?.open).toBe(false); + + const workerNameAuthProps = authenticateCalls + .map(([props]) => props) + .find((props) => props.purpose === "workerNames" && props.open === true); + + expect(workerNameAuthProps).toBeDefined(); + + await act(async () => { + workerNameAuthProps?.onAuthenticated("testuser", "testpass"); + }); + + await waitFor(() => { + const latestBulkWorkerNameModalProps = bulkWorkerNameModalCalls[bulkWorkerNameModalCalls.length - 1]?.[0]; + expect(latestBulkWorkerNameModalProps?.open).toBe(true); + expect(latestBulkWorkerNameModalProps?.getWorkerNameCredentials?.()).toEqual({ + username: "testuser", + password: "testpass", + }); + }); + }); + + test("opens the bulk worker-name modal with the capability-filtered target set", async () => { + mockWithCapabilityCheck.mockImplementationOnce(async () => {}); + mockUseMinerActions.mockReturnValueOnce({ + ...createMockMinerActionsReturn(null), + popoverActions: [ + { + action: settingsActions.miningPool, + title: "Edit pool", + icon: null, + actionHandler: vi.fn(), + requiresConfirmation: false, + }, + ], + }); + + render( + , + ); + + const widgetCalls = mockBulkActionsWidget.mock.calls as unknown as Array< + [{ actions: Array<{ action: string; actionHandler: () => void }> }] + >; + const updateWorkerNamesAction = widgetCalls[0]?.[0].actions.find( + (action) => action.action === settingsActions.updateWorkerNames, + ); + + await act(async () => { + updateWorkerNamesAction?.actionHandler(); + }); + + const capabilityCheckCallback = mockWithCapabilityCheck.mock.calls[0]?.[1] as + | ((filteredSelector?: unknown, filteredDeviceIds?: string[]) => void) + | undefined; + + await act(async () => { + capabilityCheckCallback?.( + { selectionType: { case: "includeDevices", value: { deviceIdentifiers: ["miner-2"] } } }, + ["miner-2"], + ); + }); + + const workerNameAuthProps = ( + mockAuthenticateFleetModal.mock.calls as unknown as Array< + [{ purpose?: string; open: boolean; onAuthenticated: (username: string, password: string) => void }] + > + ) + .map(([props]) => props) + .find((props) => props.purpose === "workerNames" && props.open === true); + + expect(workerNameAuthProps).toBeDefined(); + + await act(async () => { + workerNameAuthProps?.onAuthenticated("testuser", "testpass"); + }); + + await waitFor(() => { + const latestBulkWorkerNameModalProps = ( + mockBulkWorkerNameModal.mock.calls as unknown as Array< + [ + { + open: boolean; + selectedMinerIds: string[]; + selectionMode: string; + originalSelectionMode?: string; + totalCount?: number; + }, + ] + > + )[mockBulkWorkerNameModal.mock.calls.length - 1]?.[0]; + + expect(latestBulkWorkerNameModalProps?.open).toBe(true); + expect(latestBulkWorkerNameModalProps?.selectedMinerIds).toEqual(["miner-2"]); + expect(latestBulkWorkerNameModalProps?.selectionMode).toBe("subset"); + expect(latestBulkWorkerNameModalProps?.originalSelectionMode).toBe("all"); + expect(latestBulkWorkerNameModalProps?.totalCount).toBe(1); + }); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/MinerActionsMenu.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/MinerActionsMenu.tsx new file mode 100644 index 000000000..b21723c78 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/MinerActionsMenu.tsx @@ -0,0 +1,361 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import PoolSelectionPageWrapper from "../ActionBar/SettingsWidget/PoolSelectionPage"; +import BulkActionsWidget, { BulkActionsPopover } from "../BulkActions"; +import { type BulkAction } from "../BulkActions/types"; +import { insertActionAfter, insertActionBefore } from "./actionMenuUtils"; +import AddToGroupModal from "./AddToGroupModal"; +import BulkRenameModal from "./BulkRenameModal"; +import BulkWorkerNameModal from "./BulkWorkerNameModal"; +import { deviceActions, groupActions, performanceActions, settingsActions, SupportedAction } from "./constants"; +import CoolingModeModal from "./CoolingModeModal"; +import FirmwareUpdateModal from "./FirmwareUpdateModal"; +import ManagePowerModal from "./ManagePowerModal"; +import { ManageSecurityModal, UpdateMinerPasswordModal } from "./ManageSecurity"; +import { useMinerActions } from "./useMinerActions"; +import type { SortConfig } from "@/protoFleet/api/generated/common/v1/sort_pb"; +import type { + MinerListFilter, + MinerStateSnapshot, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import AuthenticateFleetModal from "@/protoFleet/features/auth/components/AuthenticateFleetModal"; +import { useBatchOperations } from "@/protoFleet/features/fleetManagement/hooks/useBatchOperations"; +import { ChevronDown, Edit, MiningPools } from "@/shared/assets/icons"; +import { iconSizes } from "@/shared/assets/icons/constants"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import { type SelectionMode } from "@/shared/components/List"; +import { PopoverProvider } from "@/shared/components/Popover"; +import { useWindowDimensions } from "@/shared/hooks/useWindowDimensions"; + +interface MinerActionsMenuProps { + selectedMiners: string[]; + selectionMode: SelectionMode; + /** Total count of all miners in fleet (used for "all" mode confirmation dialogs) */ + totalCount?: number; + /** Active UI filter — forwarded for "all" mode unpair */ + currentFilter?: MinerListFilter; + /** Active UI sort — forwarded so bulk actions can match visible table order. */ + currentSort?: SortConfig; + /** Miner data keyed by device identifier, forwarded to bulk rename modals. */ + miners?: Record; + /** Ordered list of miner device identifiers, forwarded to bulk rename modals. */ + minerIds?: string[]; + /** Callback to refetch miners after bulk rename or worker-name update. */ + onRefetchMiners?: () => void; + onWorkerNameUpdated?: (deviceIdentifier: string, workerName: string) => void; + onActionStart?: () => void; + onActionComplete?: () => void; +} + +type BulkWorkerNameTarget = { + selectedMinerIds: string[]; + selectionMode: SelectionMode; + originalSelectionMode: SelectionMode; + totalCount?: number; +}; + +const MinerActionsMenu = ({ + selectedMiners, + selectionMode, + totalCount, + currentFilter, + currentSort, + miners = {}, + minerIds = [], + onRefetchMiners, + onWorkerNameUpdated, + onActionStart, + onActionComplete, +}: MinerActionsMenuProps) => { + const { startBatchOperation, completeBatchOperation, removeDevicesFromBatch } = useBatchOperations(); + const [showBulkRenameModal, setShowBulkRenameModal] = useState(false); + const [showBulkWorkerNameModal, setShowBulkWorkerNameModal] = useState(false); + const [showWorkerNameAuthenticateModal, setShowWorkerNameAuthenticateModal] = useState(false); + const [bulkWorkerNameTarget, setBulkWorkerNameTarget] = useState(null); + const workerNameCredentialsRef = useRef<{ username: string; password: string } | undefined>(undefined); + const { isPhone, isTablet } = useWindowDimensions(); + const selectedMinersWithStatus = useMemo( + () => selectedMiners.map((id) => ({ deviceIdentifier: id })), + [selectedMiners], + ); + + const { + currentAction, + popoverActions, + handleConfirmation, + handleCancel, + handleMiningPoolSuccess, + handleMiningPoolError, + showPoolSelectionPage, + poolFilteredDeviceIds, + fleetCredentials, + showManagePowerModal, + handleManagePowerConfirm, + handleManagePowerDismiss, + showFirmwareUpdateModal, + handleFirmwareUpdateConfirm, + handleFirmwareUpdateDismiss, + showCoolingModeModal, + coolingModeCount, + currentCoolingMode, + handleCoolingModeConfirm, + handleCoolingModeDismiss, + showAuthenticateFleetModal, + authenticationPurpose, + showUpdatePasswordModal, + hasThirdPartyMiners, + handleFleetAuthenticated, + handlePasswordConfirm, + handlePasswordDismiss, + handleAuthDismiss, + withCapabilityCheck, + unsupportedMinersInfo, + handleUnsupportedMinersContinue, + handleUnsupportedMinersDismiss, + showManageSecurityModal, + minerGroups, + handleUpdateGroup, + handleSecurityModalClose, + showAddToGroupModal, + handleAddToGroupDismiss, + displayCount, + } = useMinerActions({ + selectedMiners: selectedMinersWithStatus, + selectionMode, + totalCount, + currentFilter, + startBatchOperation, + completeBatchOperation, + removeDevicesFromBatch, + miners, + onRefetchMiners, + onActionStart, + onActionComplete, + }); + + const handleWorkerNameFlowComplete = useCallback(() => { + setShowBulkWorkerNameModal(false); + setShowWorkerNameAuthenticateModal(false); + setBulkWorkerNameTarget(null); + workerNameCredentialsRef.current = undefined; + onActionComplete?.(); + }, [onActionComplete]); + + const prepareBulkWorkerNameTarget = useCallback( + (_filteredSelector?: unknown, filteredDeviceIds?: string[]) => { + setBulkWorkerNameTarget({ + selectedMinerIds: filteredDeviceIds ?? selectedMiners, + selectionMode: filteredDeviceIds ? "subset" : selectionMode, + originalSelectionMode: selectionMode, + totalCount: filteredDeviceIds ? filteredDeviceIds.length : totalCount, + }); + setShowWorkerNameAuthenticateModal(true); + }, + [selectedMiners, selectionMode, totalCount], + ); + + const handleBulkWorkerNamesOpen = useCallback(() => { + onActionStart?.(); + void withCapabilityCheck(settingsActions.updateWorkerNames, prepareBulkWorkerNameTarget); + }, [onActionStart, prepareBulkWorkerNameTarget, withCapabilityCheck]); + + const getWorkerNameCredentials = useCallback(() => workerNameCredentialsRef.current, []); + + const actionsWithBulkRename = useMemo(() => { + const renameAction: BulkAction = { + action: settingsActions.rename, + title: "Rename", + icon: , + actionHandler: () => { + setShowBulkRenameModal(true); + onActionStart?.(); + }, + requiresConfirmation: false, + }; + + const updateWorkerNamesAction: BulkAction = { + action: settingsActions.updateWorkerNames, + title: "Update worker names", + icon: , + actionHandler: handleBulkWorkerNamesOpen, + requiresConfirmation: false, + }; + + const actions = insertActionAfter(popoverActions, settingsActions.miningPool, updateWorkerNamesAction); + const actionsWithRenameBeforeGroup = insertActionBefore(actions, groupActions.addToGroup, renameAction); + + if (actionsWithRenameBeforeGroup !== actions) { + return actionsWithRenameBeforeGroup; + } + + const actionsWithRenameBeforeSecurity = insertActionBefore(actions, settingsActions.security, { + ...renameAction, + showGroupDivider: true, + }); + + if (actionsWithRenameBeforeSecurity !== actions) { + return actionsWithRenameBeforeSecurity; + } + + return [...actions, renameAction]; + }, [handleBulkWorkerNamesOpen, onActionStart, popoverActions]); + + const poolMiners = useMemo(() => { + if (poolFilteredDeviceIds) { + return poolFilteredDeviceIds.map((id) => ({ deviceIdentifier: id })); + } + return selectedMinersWithStatus; + }, [poolFilteredDeviceIds, selectedMinersWithStatus]); + + const showQuickActions = !isPhone && !isTablet; + const quickActions = useMemo(() => { + const quickActionOrder: SupportedAction[] = [ + deviceActions.blinkLEDs, + deviceActions.reboot, + performanceActions.managePower, + ]; + const actionMap = new Map(actionsWithBulkRename.map((action) => [action.action, action])); + + return quickActionOrder.flatMap((actionKey) => { + const action = actionMap.get(actionKey); + return action ? [action] : []; + }); + }, [actionsWithBulkRename]); + + return ( + +
+ + buttonIconSuffix={} + buttonTitle={showQuickActions ? "More" : "Actions"} + actions={actionsWithBulkRename} + onConfirmation={handleConfirmation} + onCancel={handleCancel} + currentAction={currentAction} + renderQuickActions={(onAction) => + showQuickActions + ? quickActions.map((action) => ( + + )) + : null + } + renderPopover={(beforeEach) => ( + + actions={actionsWithBulkRename} + beforeEach={beforeEach} + testId="actions-menu-popover" + /> + )} + testId="actions-menu" + unsupportedMinersInfo={unsupportedMinersInfo} + onUnsupportedMinersContinue={handleUnsupportedMinersContinue} + onUnsupportedMinersDismiss={handleUnsupportedMinersDismiss} + /> +
+ + + + + + { + workerNameCredentialsRef.current = { username, password }; + setShowWorkerNameAuthenticateModal(false); + setShowBulkWorkerNameModal(true); + }} + onDismiss={handleWorkerNameFlowComplete} + /> + + + + { + setShowBulkRenameModal(false); + onActionComplete?.(); + }} + /> + +
+ ); +}; + +export default MinerActionsMenu; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameMinerDialog.stories.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameMinerDialog.stories.tsx new file mode 100644 index 000000000..fdbb48c72 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameMinerDialog.stories.tsx @@ -0,0 +1,36 @@ +import { useState } from "react"; +import { action } from "storybook/actions"; +import RenameMinerDialog from "./RenameMinerDialog"; + +export default { + title: "Proto Fleet/Fleet Management/RenameMinerDialog", + component: RenameMinerDialog, +}; + +export const Default = () => { + const [open, setOpen] = useState(true); + + return ( + <> + {!open && ( +
+ +
+ )} + { + action("onConfirm")(name); + setOpen(false); + }} + onDismiss={() => { + action("onDismiss")(); + setOpen(false); + }} + /> + + ); +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameMinerDialog.test.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameMinerDialog.test.tsx new file mode 100644 index 000000000..fb1a81413 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameMinerDialog.test.tsx @@ -0,0 +1,279 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import RenameMinerDialog from "./RenameMinerDialog"; +import Input from "@/shared/components/Input"; + +vi.mock("@/shared/components/Modal/Modal", () => ({ + default: vi.fn( + ({ + open, + children, + buttons, + onDismiss, + title, + }: { + open: boolean; + children: React.ReactNode; + buttons?: { text: string; onClick: () => void; variant?: string; dismissModalOnClick?: boolean }[]; + onDismiss: () => void; + title: string; + }) => { + if (!open) return null; + return ( +
+

{title}

+ {children} + + {buttons?.map((button, index) => ( + + ))} +
+ ); + }, + ), +})); + +vi.mock("@/shared/components/Dialog", () => ({ + default: vi.fn(({ open, title, buttons }) => { + if (!open) return null; + return ( +
+

{title}

+ {buttons?.map((button: { text: string; onClick: () => void }, index: number) => ( + + ))} +
+ ); + }), +})); + +vi.mock("@/shared/components/Input", () => ({ + default: vi.fn(({ id, label, initValue, onChange, onKeyDown, testId }) => ( +
+ + onChange(e.target.value)} + onKeyDown={(e) => onKeyDown?.(e.key)} + data-testid={testId ?? id} + /> +
+ )), +})); + +vi.mock("@/shared/components/NamePreview", () => ({ + default: vi.fn(({ currentName, newName }: { currentName: string; newName: string }) => ( +
+ )), +})); + +describe("RenameMinerDialog", () => { + const mockOnConfirm = vi.fn(); + const mockOnDismiss = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("Preview", () => { + it("passes current name as both props on open", () => { + render( + , + ); + + const preview = screen.getByTestId("name-preview"); + expect(preview).toHaveAttribute("data-current-name", "My Miner"); + expect(preview).toHaveAttribute("data-new-name", "My Miner"); + }); + + it("passes input value as newName after typing", () => { + render( + , + ); + + fireEvent.change(screen.getByTestId("rename-miner-input"), { target: { value: "New Name" } }); + + const preview = screen.getByTestId("name-preview"); + expect(preview).toHaveAttribute("data-current-name", "My Miner"); + expect(preview).toHaveAttribute("data-new-name", "New Name"); + }); + }); + + describe("Save", () => { + it("calls onConfirm with trimmed name when Save is clicked", () => { + render( + , + ); + + fireEvent.change(screen.getByTestId("rename-miner-input"), { target: { value: " Trimmed Name " } }); + fireEvent.click(screen.getByTestId("modal-button-0")); + + expect(mockOnConfirm).toHaveBeenCalledWith("Trimmed Name"); + }); + + it("calls onConfirm when Enter key is pressed in the input", () => { + render( + , + ); + + fireEvent.change(screen.getByTestId("rename-miner-input"), { target: { value: "New Name" } }); + fireEvent.keyDown(screen.getByTestId("rename-miner-input"), { key: "Enter" }); + + expect(mockOnConfirm).toHaveBeenCalledWith("New Name"); + }); + }); + + describe("No-changes warning", () => { + it("shows warning when saving a name equal to current name with surrounding whitespace", () => { + render( + , + ); + + // Typing the trimmed equivalent should be treated as no change + fireEvent.change(screen.getByTestId("rename-miner-input"), { target: { value: "Padded Name" } }); + fireEvent.click(screen.getByTestId("modal-button-0")); + + expect(screen.getByTestId("rename-miner-no-changes-dialog")).toBeInTheDocument(); + expect(mockOnConfirm).not.toHaveBeenCalled(); + }); + + it("shows warning dialog when saving without changing the name", () => { + render( + , + ); + + // Click Save without changing the input — name equals currentName + fireEvent.click(screen.getByTestId("modal-button-0")); + + expect(mockOnConfirm).not.toHaveBeenCalled(); + expect(screen.getByTestId("rename-miner-no-changes-dialog")).toBeInTheDocument(); + }); + + it("shows warning dialog instead of calling onConfirm when name is empty", () => { + render( + , + ); + + fireEvent.change(screen.getByTestId("rename-miner-input"), { target: { value: " " } }); + fireEvent.click(screen.getByTestId("modal-button-0")); + + expect(mockOnConfirm).not.toHaveBeenCalled(); + expect(screen.getByTestId("rename-miner-no-changes-dialog")).toBeInTheDocument(); + }); + + it("returns to rename modal when 'No, keep editing' is clicked in warning", () => { + render( + , + ); + + fireEvent.change(screen.getByTestId("rename-miner-input"), { target: { value: "" } }); + fireEvent.click(screen.getByTestId("modal-button-0")); + + // "No, keep editing" is the first button (index 0) + fireEvent.click(screen.getByTestId("dialog-button-0")); + + expect(screen.getByTestId("rename-modal")).toBeInTheDocument(); + expect(screen.queryByTestId("rename-miner-no-changes-dialog")).not.toBeInTheDocument(); + }); + + it("calls onDismiss when 'Yes, continue' is clicked in warning", () => { + render( + , + ); + + fireEvent.change(screen.getByTestId("rename-miner-input"), { target: { value: "" } }); + fireEvent.click(screen.getByTestId("modal-button-0")); + + // "Yes, continue" is the second button (index 1) + fireEvent.click(screen.getByTestId("dialog-button-1")); + + expect(mockOnDismiss).toHaveBeenCalled(); + }); + }); + + describe("Input constraints", () => { + it("passes maxLength of 100 to the Input", () => { + render( + , + ); + + const [firstCallProps] = vi.mocked(Input).mock.calls[0]; + expect(firstCallProps.maxLength).toBe(100); + }); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameMinerDialog.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameMinerDialog.tsx new file mode 100644 index 000000000..43116c569 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameMinerDialog.tsx @@ -0,0 +1,106 @@ +import { useCallback, useState } from "react"; + +import { variants } from "@/shared/components/Button"; +import Dialog from "@/shared/components/Dialog"; +import Input from "@/shared/components/Input"; +import Modal from "@/shared/components/Modal/Modal"; +import NamePreview from "@/shared/components/NamePreview"; + +const maxNameLength = 100; + +interface RenameMinerDialogProps { + open: boolean; + deviceIdentifier: string; + currentMinerName?: string; + onConfirm: (name: string) => void; + onDismiss: () => void; +} + +const RenameMinerDialog = ({ + open, + deviceIdentifier, + currentMinerName, + onConfirm, + onDismiss, +}: RenameMinerDialogProps) => { + const currentName = currentMinerName || deviceIdentifier; + const [inputValue, setInputValue] = useState(currentName); + const [showNoChangesWarning, setShowNoChangesWarning] = useState(false); + + const handleChange = useCallback((value: string) => { + setInputValue(value); + }, []); + + const handleSave = useCallback(() => { + const trimmed = inputValue.trim(); + + if (trimmed === "" || trimmed === currentName.trim()) { + setShowNoChangesWarning(true); + return; + } + + onConfirm(trimmed); + }, [inputValue, onConfirm, currentName]); + + if (showNoChangesWarning) { + return ( + setShowNoChangesWarning(false), + }, + { + text: "Yes, continue", + variant: variants.primary, + onClick: onDismiss, + }, + ]} + /> + ); + } + + return ( + +
+ { + if (key === "Enter") handleSave(); + }} + maxLength={maxNameLength} + autoFocus + testId="rename-miner-input" + /> +
+ +
+
+
+ ); +}; + +export default RenameMinerDialog; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/CustomPropertyOptionsModal.test.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/CustomPropertyOptionsModal.test.tsx new file mode 100644 index 000000000..1f766c63c --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/CustomPropertyOptionsModal.test.tsx @@ -0,0 +1,188 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import CustomPropertyOptionsModal from "./CustomPropertyOptionsModal"; +import { customPropertyTypes } from "./types"; + +vi.mock("@/shared/components/Modal/Modal", () => ({ + default: vi.fn( + ({ + open, + children, + buttons, + title, + }: { + open: boolean; + children: React.ReactNode; + buttons?: { text: string; onClick: () => void; disabled?: boolean; testId?: string }[]; + title: string; + }) => { + if (!open) return null; + + return ( +
+

{title}

+ {children} + {buttons?.map((button, index) => ( + + ))} +
+ ); + }, + ), +})); + +vi.mock("@/shared/components/NamePreview", () => ({ + PreviewContainer: vi.fn(({ children }: { children: React.ReactNode }) => ( +
{children}
+ )), +})); + +describe("CustomPropertyOptionsModal", () => { + const onConfirm = vi.fn(); + const onDismiss = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("shows prefix, suffix, and counter fields by default", () => { + render(); + + expect(screen.getByTestId("custom-property-prefix-input")).toBeInTheDocument(); + expect(screen.getByTestId("custom-property-suffix-input")).toBeInTheDocument(); + expect(screen.getByTestId("custom-property-counter-start-input")).toBeInTheDocument(); + expect(screen.getByTestId("custom-property-counter-scale-option-1")).toBeInTheDocument(); + expect(screen.queryByTestId("custom-property-string-input")).not.toBeInTheDocument(); + expect(screen.getByText("Options")).toBeInTheDocument(); + + expect(screen.getByTestId("custom-property-counter-start-input")).toHaveValue(""); + expect(screen.getByTestId("custom-property-options-save-button")).toBeDisabled(); + expect(screen.getByText("Enter prefix, suffix, or counter to preview")).toBeInTheDocument(); + expect(screen.queryByTestId("name-preview")).not.toBeInTheDocument(); + }); + + it("applies 100 character max length to custom option inputs", () => { + render(); + + expect(screen.getByTestId("custom-property-prefix-input")).toHaveAttribute("maxLength", "100"); + expect(screen.getByTestId("custom-property-suffix-input")).toHaveAttribute("maxLength", "100"); + expect(screen.getByTestId("custom-property-counter-start-input")).toHaveAttribute("maxLength", "9"); + + fireEvent.click(screen.getByTestId("custom-property-type-button")); + fireEvent.click(screen.getByTestId(`custom-property-type-option-${customPropertyTypes.stringOnly}`)); + + expect(screen.getByTestId("custom-property-string-input")).toHaveAttribute("maxLength", "100"); + }); + + it("changes fields based on selected type", () => { + render(); + + fireEvent.click(screen.getByTestId("custom-property-type-button")); + fireEvent.click(screen.getByTestId(`custom-property-type-option-${customPropertyTypes.counterOnly}`)); + + expect(screen.queryByTestId("custom-property-prefix-input")).not.toBeInTheDocument(); + expect(screen.queryByTestId("custom-property-suffix-input")).not.toBeInTheDocument(); + expect(screen.getByTestId("custom-property-counter-start-input")).toBeInTheDocument(); + expect(screen.getByTestId("custom-property-counter-scale-option-1")).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("custom-property-type-button")); + fireEvent.click(screen.getByTestId(`custom-property-type-option-${customPropertyTypes.stringOnly}`)); + + expect(screen.getByTestId("custom-property-string-input")).toBeInTheDocument(); + expect(screen.queryByTestId("custom-property-counter-start-input")).not.toBeInTheDocument(); + expect(screen.queryByTestId("custom-property-counter-scale-option-3")).not.toBeInTheDocument(); + }); + + it("submits selected counter scale", () => { + render(); + + fireEvent.change(screen.getByTestId("custom-property-counter-start-input"), { target: { value: "7" } }); + fireEvent.click(screen.getByTestId("custom-property-counter-scale-option-6")); + fireEvent.click(screen.getByTestId("custom-property-options-save-button")); + + expect(onConfirm).toHaveBeenCalledWith( + expect.objectContaining({ + counterStart: 7, + counterScale: 6, + }), + ); + }); + + it("shows counter placeholder only for counter-only type when counter is empty", () => { + render( + , + ); + + expect(screen.getByText("Enter counter to preview")).toBeInTheDocument(); + }); + + it("shows prefix and suffix preview when counter is empty in custom string + counter", () => { + render(); + + fireEvent.change(screen.getByTestId("custom-property-prefix-input"), { target: { value: "Rack-" } }); + fireEvent.change(screen.getByTestId("custom-property-suffix-input"), { target: { value: "-A" } }); + + expect(screen.queryByText("Enter prefix, suffix, or counter to preview")).not.toBeInTheDocument(); + expect(screen.getByTestId("custom-property-preview-leading")).toHaveTextContent(""); + expect(screen.getByTestId("custom-property-preview-highlighted")).toHaveTextContent("Rack--A"); + expect(screen.getByTestId("custom-property-preview-trailing")).toHaveTextContent(""); + }); + + it("requires string input for string-only type", () => { + render( + , + ); + + const saveButton = screen.getByTestId("custom-property-options-save-button"); + expect(saveButton).toBeDisabled(); + + fireEvent.change(screen.getByTestId("custom-property-string-input"), { target: { value: " Rack A " } }); + + expect(saveButton).toBeEnabled(); + + fireEvent.click(saveButton); + expect(onConfirm).toHaveBeenCalledWith( + expect.objectContaining({ + type: customPropertyTypes.stringOnly, + stringValue: "Rack A", + }), + ); + }); + + it("renders preview in new-name-only mode", () => { + render( + , + ); + + expect(screen.getByTestId("custom-property-preview-leading")).toHaveTextContent(""); + expect(screen.getByTestId("custom-property-preview-highlighted")).toHaveTextContent("M-001"); + expect(screen.getByTestId("custom-property-preview-trailing")).toHaveTextContent(""); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/CustomPropertyOptionsModal.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/CustomPropertyOptionsModal.tsx new file mode 100644 index 000000000..571b9289e --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/CustomPropertyOptionsModal.tsx @@ -0,0 +1,205 @@ +import { useCallback, useEffect, useState } from "react"; +import { + counterScaleMaximum, + counterScaleMinimum, + counterScaleValues, + counterStartInputMaxLength, + defaultCounterScale, + renameOptionInputMaxLength, +} from "./constants"; +import CustomPropertyTypeDropdown from "./CustomPropertyTypeDropdown"; +import HighlightedNamePreview from "./HighlightedNamePreview"; +import InlineRadioGroup from "./InlineRadioGroup"; +import RenameOptionsModal, { RenameOptionsModalBody, RenameOptionsModalPreview } from "./RenameOptionsModal"; +import { type CustomPropertyOptionsValues, customPropertyTypes } from "./types"; +import Input from "@/shared/components/Input"; +import { PreviewContainer } from "@/shared/components/NamePreview"; +import { clamp } from "@/shared/utils/math"; + +const buildDefaultOptions = (initialValues?: Partial): CustomPropertyOptionsValues => ({ + type: initialValues?.type ?? customPropertyTypes.stringAndCounter, + prefix: initialValues?.prefix ?? "", + suffix: initialValues?.suffix ?? "", + counterStart: initialValues?.counterStart, + counterScale: initialValues?.counterScale ?? defaultCounterScale, + stringValue: initialValues?.stringValue ?? "", +}); + +const parseCounterStart = (inputValue: string): number | undefined => { + const parsed = Number.parseInt(inputValue.trim(), 10); + return Number.isNaN(parsed) ? undefined : parsed; +}; + +const counterScaleOptions = counterScaleValues.map((counterScale) => ({ + value: counterScale, + label: String(counterScale), + testId: `custom-property-counter-scale-option-${counterScale}`, +})); + +const previewPlaceholderLabels = { + [customPropertyTypes.stringOnly]: "Enter string to preview", + [customPropertyTypes.counterOnly]: "Enter counter to preview", + [customPropertyTypes.stringAndCounter]: "Enter prefix, suffix, or counter to preview", +} as const; + +interface CustomPropertyOptionsModalProps { + open: boolean; + previewName: string; + highlightedText?: string; + highlightStartIndex?: number; + initialValues?: Partial; + onConfirm: (nextValues: CustomPropertyOptionsValues) => void; + onDismiss: () => void; + onChange?: (nextValues: CustomPropertyOptionsValues) => void; +} + +type OpenCustomPropertyOptionsModalProps = Omit; + +const OpenCustomPropertyOptionsModal = ({ + previewName, + highlightedText, + highlightStartIndex, + initialValues, + onConfirm, + onDismiss, + onChange, +}: OpenCustomPropertyOptionsModalProps) => { + const [options, setOptions] = useState(buildDefaultOptions(initialValues)); + const [counterStartInput, setCounterStartInput] = useState( + initialValues?.counterStart === undefined ? "" : String(initialValues.counterStart), + ); + + useEffect(() => { + onChange?.(options); + // eslint-disable-next-line react-hooks/exhaustive-deps -- onChange is intentionally excluded to prevent infinite loops from unstable callback references + }, [options]); + + const updateOption = useCallback( + (key: K, value: CustomPropertyOptionsValues[K]) => { + setOptions((prev) => ({ ...prev, [key]: value })); + }, + [], + ); + + const isStringAndCounter = options.type === customPropertyTypes.stringAndCounter; + const isCounterOnly = options.type === customPropertyTypes.counterOnly; + const isStringOnly = options.type === customPropertyTypes.stringOnly; + const includesCounter = isStringAndCounter || isCounterOnly; + const missingCounter = counterStartInput.trim() === ""; + + const saveDisabled = (includesCounter && missingCounter) || (isStringOnly && options.stringValue.trim() === ""); + + const showPreviewPlaceholder = + (isStringOnly && options.stringValue.trim() === "") || + (isCounterOnly && missingCounter) || + (isStringAndCounter && missingCounter && options.prefix.trim() === "" && options.suffix.trim() === ""); + + const previewNameValue = isStringAndCounter && missingCounter ? `${options.prefix}${options.suffix}` : previewName; + + const handleConfirm = useCallback(() => { + if (saveDisabled) return; + onConfirm({ + ...options, + prefix: options.prefix.trim(), + suffix: options.suffix.trim(), + stringValue: options.stringValue.trim(), + }); + }, [onConfirm, options, saveDisabled]); + + return ( + + + updateOption("type", nextType)} + /> + + {isStringAndCounter ? ( +
+ updateOption("prefix", v)} + testId="custom-property-prefix-input" + /> + updateOption("suffix", v)} + testId="custom-property-suffix-input" + /> +
+ ) : null} + + {includesCounter ? ( + <> + { + const limited = nextValue.replace(/\D/g, "").slice(0, counterStartInputMaxLength); + setCounterStartInput(limited); + updateOption("counterStart", parseCounterStart(limited)); + }} + testId="custom-property-counter-start-input" + /> + updateOption("counterScale", clamp(v, counterScaleMinimum, counterScaleMaximum))} + /> + + ) : null} + + {isStringOnly ? ( + updateOption("stringValue", v)} + testId="custom-property-string-input" + /> + ) : null} + + + {showPreviewPlaceholder ? ( + + {previewPlaceholderLabels[options.type]} + + ) : ( + + )} + +
+
+ ); +}; + +const CustomPropertyOptionsModal = ({ open, ...props }: CustomPropertyOptionsModalProps) => { + if (!open) { + return null; + } + + return ; +}; + +export default CustomPropertyOptionsModal; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/CustomPropertyTypeDropdown.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/CustomPropertyTypeDropdown.tsx new file mode 100644 index 000000000..8fad723c5 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/CustomPropertyTypeDropdown.tsx @@ -0,0 +1,113 @@ +import { useState } from "react"; +import clsx from "clsx"; + +import { type CustomPropertyType, customPropertyTypeLabels } from "./types"; +import { ChevronDown } from "@/shared/assets/icons"; +import Button, { variants } from "@/shared/components/Button"; +import Popover, { PopoverProvider, usePopover } from "@/shared/components/Popover"; +import Row from "@/shared/components/Row"; +import { positions } from "@/shared/constants"; + +const propertyTypeOptions = Object.entries(customPropertyTypeLabels) as [CustomPropertyType, string][]; + +interface TypeDropdownContentProps { + selectedType: CustomPropertyType; + onChange: (nextType: CustomPropertyType) => void; +} + +const TypeDropdownContent = ({ selectedType, onChange }: TypeDropdownContentProps) => { + const [showTypeOptions, setShowTypeOptions] = useState(false); + const { triggerRef } = usePopover(); + + const closeOptions = () => { + setShowTypeOptions(false); + }; + + const selectType = (nextType: CustomPropertyType) => { + onChange(nextType); + closeOptions(); + }; + + return ( +
+ + {showTypeOptions ? ( + +
+ {propertyTypeOptions.map(([optionValue, optionLabel], index) => { + const isLastOption = index === propertyTypeOptions.length - 1; + const isSelected = selectedType === optionValue; + + return ( + selectType(optionValue)} + divider={!isLastOption} + testId={`custom-property-type-option-${optionValue}`} + attributes={{ role: "option", "aria-selected": isSelected ? "true" : "false" }} + compact + className="text-300 text-text-primary" + > + {optionLabel} + + ); + })} +
+
+ ) : null} +
+ ); +}; + +interface CustomPropertyTypeDropdownProps { + selectedType: CustomPropertyType; + onChange: (nextType: CustomPropertyType) => void; +} + +const CustomPropertyTypeDropdown = ({ selectedType, onChange }: CustomPropertyTypeDropdownProps) => { + return ( + + + + ); +}; + +export default CustomPropertyTypeDropdown; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/FixedValueOptionsModal.test.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/FixedValueOptionsModal.test.tsx new file mode 100644 index 000000000..8500ab236 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/FixedValueOptionsModal.test.tsx @@ -0,0 +1,116 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import FixedValueOptionsModal from "./FixedValueOptionsModal"; + +vi.mock("@/shared/components/Modal/Modal", () => ({ + default: vi.fn( + ({ + open, + children, + buttons, + title, + }: { + open: boolean; + children: React.ReactNode; + buttons?: { text: string; onClick: () => void; disabled?: boolean; testId?: string }[]; + title: string; + }) => { + if (!open) return null; + + return ( +
+

{title}

+ {children} + {buttons?.map((button, index) => ( + + ))} +
+ ); + }, + ), +})); + +describe("FixedValueOptionsModal", () => { + const onConfirm = vi.fn(); + const onDismiss = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("hides string section options when character count is All", () => { + render( + , + ); + + expect(screen.queryByTestId("fixed-value-string-section-option-first")).not.toBeInTheDocument(); + expect(screen.queryByTestId("fixed-value-string-section-option-last")).not.toBeInTheDocument(); + }); + + it("defaults to last 3 characters", () => { + render(); + + expect(screen.getByTestId("fixed-value-string-section-option-first")).toHaveTextContent("First 3 characters"); + expect(screen.getByTestId("fixed-value-string-section-option-last")).toHaveTextContent("Last 3 characters"); + }); + + it("submits selected count and section", () => { + render(); + + fireEvent.click(screen.getByTestId("fixed-value-character-count-option-3")); + fireEvent.click(screen.getByTestId("fixed-value-string-section-option-last")); + fireEvent.click(screen.getByTestId("fixed-value-options-save-button")); + + expect(onConfirm).toHaveBeenCalledWith({ + characterCount: 3, + stringSection: "last", + }); + }); + + it("highlights entire preview name when no highlightedText is provided", () => { + render( + , + ); + + expect(screen.getByTestId("fixed-value-preview-leading")).toHaveTextContent(""); + expect(screen.getByTestId("fixed-value-preview-highlighted")).toHaveTextContent("TEXA-BA-R01-001-4D5E"); + expect(screen.getByTestId("fixed-value-preview-trailing")).toHaveTextContent(""); + }); + + it("highlights the specified text in preview", () => { + render( + , + ); + + expect(screen.getByTestId("fixed-value-preview-leading")).toHaveTextContent("TEXA-"); + expect(screen.getByTestId("fixed-value-preview-highlighted")).toHaveTextContent("BA"); + expect(screen.getByTestId("fixed-value-preview-trailing")).toHaveTextContent("-R01-001-4D5E"); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/FixedValueOptionsModal.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/FixedValueOptionsModal.tsx new file mode 100644 index 000000000..a4aa9cabb --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/FixedValueOptionsModal.tsx @@ -0,0 +1,144 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { defaultFixedCharacterCount, fixedCharacterCountAll, fixedCharacterCountValues } from "./constants"; +import HighlightedNamePreview from "./HighlightedNamePreview"; +import InlineRadioGroup, { type InlineRadioOption } from "./InlineRadioGroup"; +import RenameOptionsModal, { RenameOptionsModalBody, RenameOptionsModalPreview } from "./RenameOptionsModal"; +import { fixedStringSections } from "./types"; +import type { FixedCharacterCount, FixedStringSection, FixedValueOptionsValues } from "./types"; + +const buildDefaultOptions = (initialValues?: Partial): FixedValueOptionsValues => { + return { + characterCount: initialValues?.characterCount ?? defaultFixedCharacterCount, + stringSection: initialValues?.stringSection ?? fixedStringSections.last, + }; +}; + +const characterCountOptions: InlineRadioOption[] = [ + { + value: fixedCharacterCountAll, + label: "All", + testId: "fixed-value-character-count-option-all", + }, + ...fixedCharacterCountValues.map((value) => ({ + value, + label: String(value), + testId: `fixed-value-character-count-option-${value}`, + })), +]; + +interface FixedValueOptionsModalProps { + open: boolean; + previewName: string; + highlightedText?: string; + highlightStartIndex?: number; + initialValues?: Partial; + onConfirm: (nextValues: FixedValueOptionsValues) => void; + onDismiss: () => void; + onChange?: (nextValues: FixedValueOptionsValues) => void; +} + +type OpenFixedValueOptionsModalProps = Omit; + +const OpenFixedValueOptionsModal = ({ + previewName, + highlightedText, + highlightStartIndex, + initialValues, + onConfirm, + onDismiss, + onChange, +}: OpenFixedValueOptionsModalProps) => { + const [options, setOptions] = useState(buildDefaultOptions(initialValues)); + + useEffect(() => { + onChange?.(options); + // eslint-disable-next-line react-hooks/exhaustive-deps -- onChange is intentionally excluded to prevent infinite loops from unstable callback references + }, [options]); + + const showStringSectionOptions = options.characterCount !== fixedCharacterCountAll; + const selectedCount = useMemo(() => { + if (typeof options.characterCount === "number") { + return options.characterCount; + } + + return fixedCharacterCountValues[0]; + }, [options.characterCount]); + const characterSuffix = selectedCount === 1 ? "character" : "characters"; + + const stringSectionOptions: InlineRadioOption[] = [ + { + value: fixedStringSections.first, + label: `First ${selectedCount} ${characterSuffix}`, + testId: "fixed-value-string-section-option-first", + }, + { + value: fixedStringSections.last, + label: `Last ${selectedCount} ${characterSuffix}`, + testId: "fixed-value-string-section-option-last", + }, + ]; + + const handleConfirm = useCallback(() => { + onConfirm({ + characterCount: options.characterCount, + stringSection: showStringSectionOptions ? options.stringSection : undefined, + }); + }, [onConfirm, options, showStringSectionOptions]); + + return ( + + + { + setOptions((previousValue) => ({ + ...previousValue, + characterCount: nextValue, + })); + }} + /> + + {showStringSectionOptions ? ( + { + setOptions((previousValue) => ({ + ...previousValue, + stringSection: nextValue, + })); + }} + /> + ) : null} + + + + + + + ); +}; + +const FixedValueOptionsModal = ({ open, ...props }: FixedValueOptionsModalProps) => { + if (!open) { + return null; + } + + return ; +}; + +export default FixedValueOptionsModal; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/HighlightedNamePreview.test.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/HighlightedNamePreview.test.tsx new file mode 100644 index 000000000..689b17c3e --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/HighlightedNamePreview.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import HighlightedNamePreview from "./HighlightedNamePreview"; + +describe("HighlightedNamePreview", () => { + it("highlights the matching substring when no explicit start index is provided", () => { + render(); + + expect(screen.getByTestId("highlighted-name-preview-leading")).toHaveTextContent("TEXA-"); + expect(screen.getByTestId("highlighted-name-preview-highlighted")).toHaveTextContent("BA"); + expect(screen.getByTestId("highlighted-name-preview-trailing")).toHaveTextContent("-R01-001-4D5E"); + }); + + it("uses the explicit highlight start index when the same text appears earlier in the preview", () => { + render(); + + expect(screen.getByTestId("highlighted-name-preview-leading")).toHaveTextContent("AB-"); + expect(screen.getByTestId("highlighted-name-preview-highlighted")).toHaveTextContent("AB"); + expect(screen.getByTestId("highlighted-name-preview-trailing")).toHaveTextContent("-R01"); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/HighlightedNamePreview.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/HighlightedNamePreview.tsx new file mode 100644 index 000000000..6e531344a --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/HighlightedNamePreview.tsx @@ -0,0 +1,71 @@ +import { PreviewContainer } from "@/shared/components/NamePreview"; +import { INACTIVE_PLACEHOLDER } from "@/shared/constants"; + +interface HighlightedNamePreviewProps { + previewName: string; + highlightedText?: string; + highlightStartIndex?: number; + testIdPrefix?: string; +} + +interface HighlightedPreviewSections { + leading: string; + highlighted: string; + trailing: string; +} + +const defaultTestIdPrefix = "highlighted-name-preview"; + +const buildHighlightedSections = ( + previewName: string, + highlightedText: string, + highlightStartIndex?: number, +): HighlightedPreviewSections => { + if (previewName === "" || highlightedText === "") { + return { leading: "", highlighted: previewName, trailing: "" }; + } + + const explicitHighlightMatches = + typeof highlightStartIndex === "number" && + highlightStartIndex >= 0 && + previewName.slice(highlightStartIndex, highlightStartIndex + highlightedText.length) === highlightedText; + + const index = explicitHighlightMatches ? highlightStartIndex : previewName.indexOf(highlightedText); + + if (index === -1) { + return { leading: "", highlighted: previewName, trailing: "" }; + } + + return { + leading: previewName.slice(0, index), + highlighted: highlightedText, + trailing: previewName.slice(index + highlightedText.length), + }; +}; + +const HighlightedNamePreview = ({ + previewName, + highlightedText = previewName, + highlightStartIndex, + testIdPrefix = defaultTestIdPrefix, +}: HighlightedNamePreviewProps) => { + const previewSections = buildHighlightedSections(previewName, highlightedText, highlightStartIndex); + + return ( + + {previewName === "" ? ( + {INACTIVE_PLACEHOLDER} + ) : ( + + {previewSections.leading} + + {previewSections.highlighted} + + {previewSections.trailing} + + )} + + ); +}; + +export default HighlightedNamePreview; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/InlineRadioGroup.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/InlineRadioGroup.tsx new file mode 100644 index 000000000..397cf8bb6 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/InlineRadioGroup.tsx @@ -0,0 +1,54 @@ +import { useId } from "react"; +import Radio from "@/shared/components/Radio"; + +export interface InlineRadioOption { + value: ValueType; + label: string; + testId: string; +} + +interface InlineRadioGroupProps { + label: string; + value: ValueType; + options: InlineRadioOption[]; + onChange: (nextValue: ValueType) => void; +} + +const InlineRadioGroup = ({ + label, + value, + options, + onChange, +}: InlineRadioGroupProps) => { + const groupName = useId(); + + return ( +
+ {label} +
+ {options.map((option) => { + const selected = option.value === value; + + return ( + + ); + })} +
+
+ ); +}; + +export default InlineRadioGroup; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/QualifierOptionsModal.test.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/QualifierOptionsModal.test.tsx new file mode 100644 index 000000000..5dbcffd1a --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/QualifierOptionsModal.test.tsx @@ -0,0 +1,103 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import QualifierOptionsModal from "./QualifierOptionsModal"; + +vi.mock("@/shared/components/Modal/Modal", () => ({ + default: vi.fn( + ({ + open, + children, + buttons, + title, + }: { + open: boolean; + children: React.ReactNode; + buttons?: { text: string; onClick: () => void; disabled?: boolean; testId?: string }[]; + title: string; + }) => { + if (!open) return null; + + return ( +
+

{title}

+ {children} + {buttons?.map((button, index) => ( + + ))} +
+ ); + }, + ), +})); + +describe("QualifierOptionsModal", () => { + const onConfirm = vi.fn(); + const onDismiss = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders prefix and suffix fields", () => { + render(); + + expect(screen.getByTestId("qualifier-property-prefix-input")).toBeInTheDocument(); + expect(screen.getByTestId("qualifier-property-suffix-input")).toBeInTheDocument(); + expect(screen.getByTestId("qualifier-property-prefix-input")).toHaveAttribute("maxLength", "100"); + expect(screen.getByTestId("qualifier-property-suffix-input")).toHaveAttribute("maxLength", "100"); + }); + + it("submits trimmed prefix and suffix", () => { + render(); + + fireEvent.change(screen.getByTestId("qualifier-property-prefix-input"), { target: { value: " B1 " } }); + fireEvent.change(screen.getByTestId("qualifier-property-suffix-input"), { target: { value: " -R4 " } }); + + fireEvent.click(screen.getByTestId("qualifier-options-save-button")); + + expect(onConfirm).toHaveBeenCalledWith({ + prefix: "B1", + suffix: "-R4", + }); + }); + + it("highlights entire preview name when no highlightedText is provided", () => { + render( + , + ); + + expect(screen.getByTestId("qualifier-preview-leading")).toHaveTextContent(""); + expect(screen.getByTestId("qualifier-preview-highlighted")).toHaveTextContent("TEXA-BA-R01-001-4D5E"); + expect(screen.getByTestId("qualifier-preview-trailing")).toHaveTextContent(""); + }); + + it("highlights the specified text in preview", () => { + render( + , + ); + + expect(screen.getByTestId("qualifier-preview-leading")).toHaveTextContent("TEXA-"); + expect(screen.getByTestId("qualifier-preview-highlighted")).toHaveTextContent("BA"); + expect(screen.getByTestId("qualifier-preview-trailing")).toHaveTextContent("-R01-001-4D5E"); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/QualifierOptionsModal.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/QualifierOptionsModal.tsx new file mode 100644 index 000000000..552e7975a --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/QualifierOptionsModal.tsx @@ -0,0 +1,110 @@ +import { useCallback, useEffect, useState } from "react"; + +import { renameOptionInputMaxLength } from "./constants"; +import HighlightedNamePreview from "./HighlightedNamePreview"; +import RenameOptionsModal, { RenameOptionsModalBody, RenameOptionsModalPreview } from "./RenameOptionsModal"; +import { type QualifierOptionsValues } from "./types"; +import Input from "@/shared/components/Input"; + +const buildDefaultOptions = (initialValues?: Partial): QualifierOptionsValues => { + return { + prefix: initialValues?.prefix ?? "", + suffix: initialValues?.suffix ?? "", + }; +}; + +interface QualifierOptionsModalProps { + open: boolean; + previewName: string; + highlightedText?: string; + highlightStartIndex?: number; + initialValues?: Partial; + onConfirm: (nextValues: QualifierOptionsValues) => void; + onDismiss: () => void; + onChange?: (nextValues: QualifierOptionsValues) => void; +} + +type OpenQualifierOptionsModalProps = Omit; + +const OpenQualifierOptionsModal = ({ + previewName, + highlightedText, + highlightStartIndex, + initialValues, + onConfirm, + onDismiss, + onChange, +}: OpenQualifierOptionsModalProps) => { + const [options, setOptions] = useState(buildDefaultOptions(initialValues)); + + useEffect(() => { + onChange?.(options); + // eslint-disable-next-line react-hooks/exhaustive-deps -- onChange is intentionally excluded to prevent infinite loops from unstable callback references + }, [options]); + + const handleConfirm = useCallback(() => { + onConfirm({ + prefix: options.prefix.trim(), + suffix: options.suffix.trim(), + }); + }, [onConfirm, options.prefix, options.suffix]); + + return ( + + +
+ { + setOptions((previousValue) => ({ + ...previousValue, + prefix: nextValue, + })); + }} + testId="qualifier-property-prefix-input" + /> + { + setOptions((previousValue) => ({ + ...previousValue, + suffix: nextValue, + })); + }} + testId="qualifier-property-suffix-input" + /> +
+ + + + +
+
+ ); +}; + +const QualifierOptionsModal = ({ open, ...props }: QualifierOptionsModalProps) => { + if (!open) { + return null; + } + + return ; +}; + +export default QualifierOptionsModal; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/RenameOptionsModal.stories.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/RenameOptionsModal.stories.tsx new file mode 100644 index 000000000..8f6474d98 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/RenameOptionsModal.stories.tsx @@ -0,0 +1,36 @@ +import { action } from "storybook/actions"; +import RenameOptionsModal, { RenameOptionsModalBody } from "./RenameOptionsModal"; +import Input from "@/shared/components/Input"; + +export default { + title: "Proto Fleet/Fleet Management/RenameOptionsModal", + component: RenameOptionsModal, +}; + +export const Default = () => ( + action("onConfirm")()} + onDismiss={() => action("onDismiss")()} + desktopSaveTestId="save-desktop" + mobileSaveTestId="save-mobile" + > + + {}} /> + {}} /> + + +); + +export const SaveDisabled = () => ( + action("onConfirm")()} + onDismiss={() => action("onDismiss")()} + desktopSaveTestId="save-desktop" + mobileSaveTestId="save-mobile" + saveDisabled + > + + {}} /> + + +); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/RenameOptionsModal.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/RenameOptionsModal.tsx new file mode 100644 index 000000000..5ef9d6d2a --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/RenameOptionsModal.tsx @@ -0,0 +1,80 @@ +import { type ReactNode } from "react"; + +import { variants } from "@/shared/components/Button"; +import Modal from "@/shared/components/Modal/Modal"; + +interface RenameOptionsModalProps { + children: ReactNode; + onConfirm: () => void; + onDismiss: () => void; + desktopSaveTestId: string; + mobileSaveTestId: string; + saveDisabled?: boolean; +} + +interface RenameOptionsModalSectionProps { + children: ReactNode; +} +const buildModalActions = ( + onDismiss: () => void, + onConfirm: () => void, + desktopSaveTestId: string, + mobileSaveTestId: string, + saveDisabled: boolean, +) => ({ + buttons: [ + { + text: "Save", + variant: variants.primary, + onClick: onConfirm, + disabled: saveDisabled, + testId: desktopSaveTestId, + }, + ], + phoneFooterButtons: [ + { + text: "Cancel", + variant: variants.secondary, + onClick: onDismiss, + }, + { + text: "Save", + variant: variants.primary, + onClick: onConfirm, + disabled: saveDisabled, + testId: mobileSaveTestId, + }, + ], +}); + +const RenameOptionsModal = ({ + children, + onConfirm, + onDismiss, + desktopSaveTestId, + mobileSaveTestId, + saveDisabled = false, +}: RenameOptionsModalProps) => ( + + {children} + +); + +export const RenameOptionsModalBody = ({ children }: RenameOptionsModalSectionProps) => { + return
{children}
; +}; + +export const RenameOptionsModalPreview = ({ children }: RenameOptionsModalSectionProps) => { + return
{children}
; +}; + +export default RenameOptionsModal; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/RenameOptionsModals.stories.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/RenameOptionsModals.stories.tsx new file mode 100644 index 000000000..1a4cdfb0e --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/RenameOptionsModals.stories.tsx @@ -0,0 +1,133 @@ +import { type ReactNode, useState } from "react"; +import { action } from "storybook/actions"; + +import { buildFixedPreview, buildQualifierPreview } from "./storyPreviewBuilders"; +import { + CustomPropertyOptionsModal, + type CustomPropertyOptionsValues, + customPropertyTypes, + FixedValueOptionsModal, + type FixedValueOptionsValues, + QualifierOptionsModal, + type QualifierOptionsValues, +} from "./index"; +import { padLeft } from "@/shared/utils/stringUtils"; + +export default { + title: "Proto Fleet/Fleet Management/Bulk Rename/Options Modals", +}; + +const StoryContainer = ({ children }: { children: ReactNode }) => { + return
{children}
; +}; + +const buildCustomPreview = (values: CustomPropertyOptionsValues) => { + if (values.type === customPropertyTypes.stringOnly) { + return values.stringValue; + } + + if (values.counterStart === undefined) { + return `${values.prefix}${values.suffix}`; + } + + const counterValue = padLeft(values.counterStart, values.counterScale); + + if (values.type === customPropertyTypes.counterOnly) { + return counterValue; + } + + return `${values.prefix}${counterValue}${values.suffix}`; +}; + +const customOptionsInitialValues: CustomPropertyOptionsValues = { + type: customPropertyTypes.stringAndCounter, + prefix: "Building-A-", + suffix: "-R01", + counterStart: 7, + counterScale: 3, + stringValue: "Rack-A", +}; + +export const CustomOptions = () => { + const [open, setOpen] = useState(true); + const [previewName, setPreviewName] = useState(buildCustomPreview(customOptionsInitialValues)); + + return ( + + setPreviewName(buildCustomPreview(nextValues))} + onConfirm={(values) => { + action("customOptionsOnConfirm")(values); + setOpen(false); + }} + onDismiss={() => { + action("customOptionsOnDismiss")(); + setOpen(false); + }} + /> + + ); +}; + +export const FixedValueOptions = () => { + const [open, setOpen] = useState(true); + const initialValues: FixedValueOptionsValues = { + characterCount: 4, + stringSection: "first", + }; + const [preview, setPreview] = useState(buildFixedPreview(initialValues)); + + return ( + + setPreview(buildFixedPreview(nextValues))} + onConfirm={(values) => { + action("fixedValueOptionsOnConfirm")(values); + setOpen(false); + }} + onDismiss={() => { + action("fixedValueOptionsOnDismiss")(); + setOpen(false); + }} + /> + + ); +}; + +export const QualifierOptions = () => { + const [open, setOpen] = useState(true); + const initialValues: QualifierOptionsValues = { + prefix: "", + suffix: "", + }; + const [preview, setPreview] = useState(buildQualifierPreview(initialValues)); + + return ( + + setPreview(buildQualifierPreview(nextValues))} + onConfirm={(values) => { + action("qualifierOptionsOnConfirm")(values); + setOpen(false); + }} + onDismiss={() => { + action("qualifierOptionsOnDismiss")(); + setOpen(false); + }} + /> + + ); +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/constants.ts b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/constants.ts new file mode 100644 index 000000000..d168426e3 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/constants.ts @@ -0,0 +1,13 @@ +export const counterStartInputMaxLength = 9; + +export const counterScaleMinimum = 1; +export const defaultCounterScale = counterScaleMinimum; +export const counterScaleMaximum = 6; + +export const counterScaleValues = [1, 2, 3, 4, 5, 6] as const; + +export const fixedCharacterCountAll = "all" as const; +export const defaultFixedCharacterCount = 3; +export const fixedCharacterCountValues = [1, 2, 3, 4, 5, 6] as const; + +export const renameOptionInputMaxLength = 100; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/index.ts b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/index.ts new file mode 100644 index 000000000..3587e3b29 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/index.ts @@ -0,0 +1,14 @@ +export { default as CustomPropertyOptionsModal } from "./CustomPropertyOptionsModal"; +export { default as FixedValueOptionsModal } from "./FixedValueOptionsModal"; +export { default as QualifierOptionsModal } from "./QualifierOptionsModal"; + +export { customPropertyTypeLabels, customPropertyTypes, fixedStringSections } from "./types"; + +export type { + CustomPropertyOptionsValues, + CustomPropertyType, + FixedCharacterCount, + FixedStringSection, + FixedValueOptionsValues, + QualifierOptionsValues, +} from "./types"; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/storyPreviewBuilders.test.ts b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/storyPreviewBuilders.test.ts new file mode 100644 index 000000000..ef6613001 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/storyPreviewBuilders.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import { buildFixedPreview, buildQualifierPreview } from "./storyPreviewBuilders"; +import { fixedStringSections } from "./types"; + +describe("storyPreviewBuilders", () => { + it("keeps the original character order when selecting last characters", () => { + const preview = buildFixedPreview({ + characterCount: 6, + stringSection: fixedStringSections.last, + }); + + expect(preview.previewName).toBe("EXAUST-BA-R01-001-4D5E"); + expect(preview.highlightedText).toBe("EXAUST"); + expect(preview.highlightStartIndex).toBe(0); + }); + + it("uses the beginning of the section when selecting first characters", () => { + const preview = buildFixedPreview({ + characterCount: 4, + stringSection: fixedStringSections.first, + }); + + expect(preview.previewName).toBe("TEXA-BA-R01-001-4D5E"); + expect(preview.highlightedText).toBe("TEXA"); + expect(preview.highlightStartIndex).toBe(0); + }); + + it("builds qualifier preview with BA as the editable building section", () => { + const preview = buildQualifierPreview({ + prefix: "", + suffix: "", + }); + + expect(preview.previewName).toBe("TEXAUST-BA-R01-001-4D5E"); + expect(preview.highlightedText).toBe("BA"); + expect(preview.highlightStartIndex).toBe(8); + }); + + it("includes prefix and suffix in the highlighted text", () => { + const preview = buildQualifierPreview({ + prefix: "as-", + suffix: "-da", + }); + + expect(preview.previewName).toBe("TEXAUST-as-BA-da-R01-001-4D5E"); + expect(preview.highlightedText).toBe("as-BA-da"); + expect(preview.highlightStartIndex).toBe(8); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/storyPreviewBuilders.ts b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/storyPreviewBuilders.ts new file mode 100644 index 000000000..3b299d0cc --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/storyPreviewBuilders.ts @@ -0,0 +1,60 @@ +import { fixedStringSections, type FixedValueOptionsValues, type QualifierOptionsValues } from "./types"; + +const sectionSeparator = "-"; + +export const baseMinerNameSections = { + location: "TEXAUST", + building: "BA", + rack: "R01", + position: "001", + suffix: "4D5E", +} as const; + +interface PreviewResult { + previewName: string; + highlightedText: string; + highlightStartIndex: number; +} + +export const buildFixedPreview = (values: FixedValueOptionsValues): PreviewResult => { + let selectedLocationSection: string = baseMinerNameSections.location; + + if (typeof values.characterCount === "number") { + const locationCharacterCount = Math.min(values.characterCount, baseMinerNameSections.location.length); + + if (values.stringSection === fixedStringSections.last) { + const startIndex = baseMinerNameSections.location.length - locationCharacterCount; + selectedLocationSection = baseMinerNameSections.location.slice(startIndex); + } else { + selectedLocationSection = baseMinerNameSections.location.slice(0, locationCharacterCount); + } + } + + const previewName = [ + selectedLocationSection, + baseMinerNameSections.building, + baseMinerNameSections.rack, + baseMinerNameSections.position, + baseMinerNameSections.suffix, + ].join(sectionSeparator); + + return { previewName, highlightedText: selectedLocationSection, highlightStartIndex: 0 }; +}; + +export const buildQualifierPreview = (values: QualifierOptionsValues): PreviewResult => { + const qualifiedBuildingSection = `${values.prefix}${baseMinerNameSections.building}${values.suffix}`; + + const previewName = [ + baseMinerNameSections.location, + qualifiedBuildingSection, + baseMinerNameSections.rack, + baseMinerNameSections.position, + baseMinerNameSections.suffix, + ].join(sectionSeparator); + + return { + previewName, + highlightedText: qualifiedBuildingSection, + highlightStartIndex: baseMinerNameSections.location.length + sectionSeparator.length, + }; +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/types.ts b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/types.ts new file mode 100644 index 000000000..15d170a62 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/types.ts @@ -0,0 +1,43 @@ +import { fixedCharacterCountAll, fixedCharacterCountValues } from "./constants"; + +export const customPropertyTypes = { + stringAndCounter: "string-and-counter", + counterOnly: "counter-only", + stringOnly: "string-only", +} as const; + +export type CustomPropertyType = (typeof customPropertyTypes)[keyof typeof customPropertyTypes]; + +export const customPropertyTypeLabels: Record = { + [customPropertyTypes.stringAndCounter]: "Custom string + counter", + [customPropertyTypes.counterOnly]: "Counter only", + [customPropertyTypes.stringOnly]: "String only", +}; + +export interface CustomPropertyOptionsValues { + type: CustomPropertyType; + prefix: string; + suffix: string; + counterStart?: number; + counterScale: number; + stringValue: string; +} + +export type FixedCharacterCount = typeof fixedCharacterCountAll | (typeof fixedCharacterCountValues)[number]; + +export const fixedStringSections = { + first: "first", + last: "last", +} as const; + +export type FixedStringSection = (typeof fixedStringSections)[keyof typeof fixedStringSections]; + +export interface FixedValueOptionsValues { + characterCount: FixedCharacterCount; + stringSection?: FixedStringSection; +} + +export interface QualifierOptionsValues { + prefix: string; + suffix: string; +} diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/SingleMinerActionsMenu.stories.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/SingleMinerActionsMenu.stories.tsx new file mode 100644 index 000000000..45ec8dfde --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/SingleMinerActionsMenu.stories.tsx @@ -0,0 +1,78 @@ +import { action } from "storybook/actions"; +import { SingleMinerActionsMenu } from "."; + +export const Default = () => { + return ( +
+
+ Miner-001 + +
+
+ ); +}; + +export const InTable = () => { + return ( +
+ + + + + + + + + + {["Miner-001", "Miner-002", "Miner-003", "Miner-004", "Miner-005"].map((name, index) => ( + + + + + + ))} + +
NameStatusHashrate
+
+ {name} + +
+
{index % 2 === 0 ? "Online" : "Offline"}{100 + index * 10} TH/s
+
+ ); +}; + +export const MultipleInList = () => { + return ( +
+
+ {["Miner-001", "Miner-002", "Miner-003", "Miner-004"].map((name) => ( +
+ {name} + +
+ ))} +
+
+ ); +}; + +export default { + title: "Proto Fleet/Miner Actions Menu/Single Miner Actions Menu", + component: SingleMinerActionsMenu, +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/SingleMinerActionsMenu.test.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/SingleMinerActionsMenu.test.tsx new file mode 100644 index 000000000..28bdca80e --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/SingleMinerActionsMenu.test.tsx @@ -0,0 +1,828 @@ +import { Fragment, type ReactNode } from "react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { deviceActions, settingsActions } from "./constants"; +import SingleMinerActionsMenu from "./SingleMinerActionsMenu"; + +const mockWindowOpen = vi.fn(); +vi.stubGlobal("open", mockWindowOpen); + +const { + mockAuthenticateFleetModal, + mockBulkActionConfirmDialog, + mockWithCapabilityCheck, + mockPushToast, + mockRemoveToast, + mockStreamCommandBatchUpdates, + mockUpdateSingleWorkerName, + mockUpdateToast, + mockUpdateWorkerNameDialog, + mockUseMinerCommand, + mockUseMinerActions, + mockUseUpdateWorkerNames, +} = vi.hoisted(() => { + const mockWithCapabilityCheck = vi.fn(async (_action: string, onProceed: (...args: unknown[]) => void) => { + onProceed(undefined, undefined); + }); + const mockUpdateSingleWorkerName = vi.fn(); + const mockStreamCommandBatchUpdates = vi.fn(); + + return { + mockAuthenticateFleetModal: vi.fn(() => null), + mockBulkActionConfirmDialog: vi.fn(() => null), + mockWithCapabilityCheck, + mockPushToast: vi.fn(() => 1), + mockRemoveToast: vi.fn(), + mockStreamCommandBatchUpdates, + mockUpdateSingleWorkerName, + mockUpdateToast: vi.fn(), + mockUpdateWorkerNameDialog: vi.fn(() => null), + mockUseMinerCommand: vi.fn(() => ({ + streamCommandBatchUpdates: mockStreamCommandBatchUpdates, + })), + mockUseMinerActions: vi.fn(() => ({ + currentAction: null, + popoverActions: [] as any[], + handleConfirmation: vi.fn(), + handleCancel: vi.fn(), + handleMiningPoolSuccess: vi.fn(), + handleMiningPoolError: vi.fn(), + showPoolSelectionPage: false, + fleetCredentials: undefined, + showManagePowerModal: false, + handleManagePowerConfirm: vi.fn(), + handleManagePowerDismiss: vi.fn(), + showFirmwareUpdateModal: false, + handleFirmwareUpdateConfirm: vi.fn(), + handleFirmwareUpdateDismiss: vi.fn(), + showCoolingModeModal: false, + coolingModeCount: 0, + currentCoolingMode: undefined, + handleCoolingModeConfirm: vi.fn(), + handleCoolingModeDismiss: vi.fn(), + showAuthenticateFleetModal: false, + authenticationPurpose: null, + showUpdatePasswordModal: false, + hasThirdPartyMiners: false, + handleFleetAuthenticated: vi.fn(), + handlePasswordConfirm: vi.fn(), + handlePasswordDismiss: vi.fn(), + handleAuthDismiss: vi.fn(), + withCapabilityCheck: mockWithCapabilityCheck, + unsupportedMinersInfo: { + visible: false, + unsupportedGroups: [], + totalUnsupportedCount: 0, + noneSupported: false, + }, + handleUnsupportedMinersContinue: vi.fn(), + handleUnsupportedMinersDismiss: vi.fn(), + showManageSecurityModal: false, + minerGroups: [], + handleUpdateGroup: vi.fn(), + handleSecurityModalClose: vi.fn(), + showRenameDialog: false, + handleRenameOpen: vi.fn(), + handleRenameConfirm: vi.fn(), + handleRenameDismiss: vi.fn(), + showAddToGroupModal: false, + handleAddToGroupDismiss: vi.fn(), + })), + mockUseUpdateWorkerNames: vi.fn(() => ({ + updateSingleWorkerName: mockUpdateSingleWorkerName, + })), + }; +}); + +vi.mock("./useMinerActions", () => ({ + useMinerActions: mockUseMinerActions, +})); + +vi.mock("@/protoFleet/api/useUpdateWorkerNames", () => ({ + default: mockUseUpdateWorkerNames, +})); + +vi.mock("@/protoFleet/api/useMinerCommand", () => ({ + useMinerCommand: mockUseMinerCommand, +})); + +vi.mock("@/protoFleet/store/hooks/useFleet", () => ({ + useMinerDeviceStatus: vi.fn(() => undefined), +})); + +vi.mock("@/shared/components/Popover", () => ({ + PopoverProvider: ({ children }: { children: ReactNode }) => {children}, + usePopover: () => ({ + triggerRef: { current: null }, + setPopoverRenderMode: vi.fn(), + }), + popoverSizes: { small: "small" }, + default: ({ children, testId }: { children: ReactNode; testId?: string }) => ( +
{children}
+ ), +})); + +vi.mock("@/shared/hooks/useClickOutside", () => ({ + useClickOutside: vi.fn(), +})); + +vi.mock("../ActionBar/SettingsWidget/PoolSelectionPage", () => ({ + default: vi.fn(() => null), +})); + +vi.mock("./RenameMinerDialog", () => ({ + default: vi.fn(() => null), +})); + +vi.mock("./ManagePowerModal", () => ({ + default: vi.fn(() => null), +})); + +vi.mock("./FirmwareUpdateModal", () => ({ + default: vi.fn(() => null), +})); + +vi.mock("./CoolingModeModal", () => ({ + default: vi.fn(() => null), +})); + +vi.mock("@/protoFleet/features/auth/components/AuthenticateFleetModal", () => ({ + default: mockAuthenticateFleetModal, +})); + +vi.mock("./ManageSecurity", () => ({ + ManageSecurityModal: vi.fn(() => null), + UpdateMinerPasswordModal: vi.fn(() => null), +})); + +vi.mock("../BulkActions/UnsupportedMinersModal", () => ({ + default: vi.fn(() => null), +})); + +vi.mock("../BulkActions/BulkActionConfirmDialog", () => ({ + default: mockBulkActionConfirmDialog, +})); + +vi.mock("./AddToGroupModal", () => ({ + default: vi.fn(() => null), +})); + +vi.mock("./UpdateWorkerNameDialog", () => ({ + default: mockUpdateWorkerNameDialog, +})); + +vi.mock("@/shared/features/toaster", () => ({ + pushToast: mockPushToast, + removeToast: mockRemoveToast, + updateToast: mockUpdateToast, + STATUSES: { + loading: "loading", + success: "success", + error: "error", + }, +})); + +describe("SingleMinerActionsMenu", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPushToast.mockReturnValue(1); + mockStreamCommandBatchUpdates.mockResolvedValue(undefined); + }); + + it("renders 'Update worker name' when pool editing is available", () => { + mockUseMinerActions.mockReturnValue({ + currentAction: null, + popoverActions: [ + { + action: settingsActions.miningPool, + title: "Edit pool", + icon: null, + actionHandler: vi.fn(), + requiresConfirmation: false, + }, + ] as any[], + handleConfirmation: vi.fn(), + handleCancel: vi.fn(), + handleMiningPoolSuccess: vi.fn(), + handleMiningPoolError: vi.fn(), + showPoolSelectionPage: false, + fleetCredentials: undefined, + showManagePowerModal: false, + handleManagePowerConfirm: vi.fn(), + handleManagePowerDismiss: vi.fn(), + showFirmwareUpdateModal: false, + handleFirmwareUpdateConfirm: vi.fn(), + handleFirmwareUpdateDismiss: vi.fn(), + showCoolingModeModal: false, + coolingModeCount: 0, + currentCoolingMode: undefined, + handleCoolingModeConfirm: vi.fn(), + handleCoolingModeDismiss: vi.fn(), + showAuthenticateFleetModal: false, + authenticationPurpose: null, + showUpdatePasswordModal: false, + hasThirdPartyMiners: false, + handleFleetAuthenticated: vi.fn(), + handlePasswordConfirm: vi.fn(), + handlePasswordDismiss: vi.fn(), + handleAuthDismiss: vi.fn(), + withCapabilityCheck: mockWithCapabilityCheck, + unsupportedMinersInfo: { + visible: false, + unsupportedGroups: [], + totalUnsupportedCount: 0, + noneSupported: false, + }, + handleUnsupportedMinersContinue: vi.fn(), + handleUnsupportedMinersDismiss: vi.fn(), + showManageSecurityModal: false, + minerGroups: [], + handleUpdateGroup: vi.fn(), + handleSecurityModalClose: vi.fn(), + showRenameDialog: false, + handleRenameOpen: vi.fn(), + handleRenameConfirm: vi.fn(), + handleRenameDismiss: vi.fn(), + showAddToGroupModal: false, + handleAddToGroupDismiss: vi.fn(), + }); + + render(); + + fireEvent.click(screen.getByTestId("single-miner-actions-menu-button")); + + expect(screen.getByText("Update worker name")).toBeInTheDocument(); + expect(screen.getByTestId("update-worker-names-popover-button")).toBeInTheDocument(); + }); + + it("does not render 'View miner' menu item when minerUrl is not provided", () => { + render(); + + fireEvent.click(screen.getByTestId("single-miner-actions-menu-button")); + + expect(screen.queryByText("View miner")).not.toBeInTheDocument(); + }); + + it("renders 'View miner' menu item when minerUrl is provided", () => { + render(); + + fireEvent.click(screen.getByTestId("single-miner-actions-menu-button")); + + expect(screen.getByText("View miner")).toBeInTheDocument(); + expect(screen.getByTestId("viewMiner-popover-button")).toBeInTheDocument(); + }); + + it("opens miner URL in new tab when 'View miner' is clicked", () => { + const minerUrl = "http://192.168.1.42"; + render(); + + fireEvent.click(screen.getByTestId("single-miner-actions-menu-button")); + fireEvent.click(screen.getByTestId("viewMiner-popover-button")); + + expect(mockWindowOpen).toHaveBeenCalledWith(minerUrl, "_blank", "noopener,noreferrer"); + }); + + it("authenticates before updating a single worker name", async () => { + mockUpdateSingleWorkerName.mockResolvedValue({ + updatedCount: 1, + unchangedCount: 0, + failedCount: 0, + batchIdentifier: "batch-1", + }); + mockStreamCommandBatchUpdates.mockImplementation(async ({ onStreamData }) => { + onStreamData({ + status: { + commandBatchDeviceCount: { + total: 1, + success: 1, + failure: 0, + }, + }, + }); + }); + + const onActionComplete = vi.fn(); + const onRefetchMiners = vi.fn(); + const onWorkerNameUpdated = vi.fn(); + + mockUseMinerActions.mockReturnValue({ + currentAction: null, + popoverActions: [ + { + action: settingsActions.miningPool, + title: "Edit pool", + icon: null, + actionHandler: vi.fn(), + requiresConfirmation: false, + }, + ] as any[], + handleConfirmation: vi.fn(), + handleCancel: vi.fn(), + handleMiningPoolSuccess: vi.fn(), + handleMiningPoolError: vi.fn(), + showPoolSelectionPage: false, + fleetCredentials: undefined, + showManagePowerModal: false, + handleManagePowerConfirm: vi.fn(), + handleManagePowerDismiss: vi.fn(), + showFirmwareUpdateModal: false, + handleFirmwareUpdateConfirm: vi.fn(), + handleFirmwareUpdateDismiss: vi.fn(), + showCoolingModeModal: false, + coolingModeCount: 0, + currentCoolingMode: undefined, + handleCoolingModeConfirm: vi.fn(), + handleCoolingModeDismiss: vi.fn(), + showAuthenticateFleetModal: false, + authenticationPurpose: null, + showUpdatePasswordModal: false, + hasThirdPartyMiners: false, + handleFleetAuthenticated: vi.fn(), + handlePasswordConfirm: vi.fn(), + handlePasswordDismiss: vi.fn(), + handleAuthDismiss: vi.fn(), + withCapabilityCheck: mockWithCapabilityCheck, + unsupportedMinersInfo: { + visible: false, + unsupportedGroups: [], + totalUnsupportedCount: 0, + noneSupported: false, + }, + handleUnsupportedMinersContinue: vi.fn(), + handleUnsupportedMinersDismiss: vi.fn(), + showManageSecurityModal: false, + minerGroups: [], + handleUpdateGroup: vi.fn(), + handleSecurityModalClose: vi.fn(), + showRenameDialog: false, + handleRenameOpen: vi.fn(), + handleRenameConfirm: vi.fn(), + handleRenameDismiss: vi.fn(), + showAddToGroupModal: false, + handleAddToGroupDismiss: vi.fn(), + }); + + render( + , + ); + + fireEvent.click(screen.getByTestId("single-miner-actions-menu-button")); + fireEvent.click(screen.getByTestId("update-worker-names-popover-button")); + + expect(mockWithCapabilityCheck).toHaveBeenCalledWith(settingsActions.updateWorkerNames, expect.any(Function)); + + const workerNameAuthProps = ( + mockAuthenticateFleetModal.mock.calls as unknown as Array< + [{ purpose?: string; open: boolean; onAuthenticated: (username: string, password: string) => void }] + > + ) + .map(([props]) => props) + .filter((props) => props.purpose === "workerNames"); + const latestWorkerNameAuthProps = workerNameAuthProps[workerNameAuthProps.length - 1]; + + expect(latestWorkerNameAuthProps?.open).toBe(true); + + await act(async () => { + latestWorkerNameAuthProps?.onAuthenticated("testuser", "testpass"); + }); + + const updateWorkerNameDialogProps = ( + mockUpdateWorkerNameDialog.mock.calls as unknown as Array< + [{ open: boolean; currentWorkerName?: string; onConfirm: (name: string) => void }] + > + ).map(([props]) => props); + const latestUpdateWorkerNameDialogProps = updateWorkerNameDialogProps[updateWorkerNameDialogProps.length - 1]; + + expect(latestUpdateWorkerNameDialogProps?.open).toBe(true); + expect(latestUpdateWorkerNameDialogProps?.currentWorkerName).toBe("worker-old"); + + await act(async () => { + latestUpdateWorkerNameDialogProps?.onConfirm("worker-new"); + }); + + await waitFor(() => { + expect(mockUpdateSingleWorkerName).toHaveBeenCalledWith("test-device-123", "worker-new", "testuser", "testpass"); + }); + expect(mockStreamCommandBatchUpdates).toHaveBeenCalled(); + + expect(mockPushToast).toHaveBeenCalledWith({ + message: "Updating worker name", + status: "loading", + longRunning: true, + }); + expect(mockUpdateToast).toHaveBeenCalledWith(1, { + message: "Worker name updated", + status: "success", + }); + expect(onWorkerNameUpdated).toHaveBeenCalledWith("test-device-123", "worker-new"); + expect(onRefetchMiners).toHaveBeenCalledTimes(1); + expect(onActionComplete).toHaveBeenCalledTimes(1); + }); + + it("shows an unchanged toast when an async worker-name update makes no changes", async () => { + mockUpdateSingleWorkerName.mockResolvedValue({ + updatedCount: 0, + unchangedCount: 1, + failedCount: 0, + batchIdentifier: "batch-1", + }); + + const onActionComplete = vi.fn(); + const onRefetchMiners = vi.fn(); + const onWorkerNameUpdated = vi.fn(); + + mockUseMinerActions.mockReturnValue({ + currentAction: null, + popoverActions: [ + { + action: settingsActions.miningPool, + title: "Edit pool", + icon: null, + actionHandler: vi.fn(), + requiresConfirmation: false, + }, + ] as any[], + handleConfirmation: vi.fn(), + handleCancel: vi.fn(), + handleMiningPoolSuccess: vi.fn(), + handleMiningPoolError: vi.fn(), + showPoolSelectionPage: false, + fleetCredentials: undefined, + showManagePowerModal: false, + handleManagePowerConfirm: vi.fn(), + handleManagePowerDismiss: vi.fn(), + showFirmwareUpdateModal: false, + handleFirmwareUpdateConfirm: vi.fn(), + handleFirmwareUpdateDismiss: vi.fn(), + showCoolingModeModal: false, + coolingModeCount: 0, + currentCoolingMode: undefined, + handleCoolingModeConfirm: vi.fn(), + handleCoolingModeDismiss: vi.fn(), + showAuthenticateFleetModal: false, + authenticationPurpose: null, + showUpdatePasswordModal: false, + hasThirdPartyMiners: false, + handleFleetAuthenticated: vi.fn(), + handlePasswordConfirm: vi.fn(), + handlePasswordDismiss: vi.fn(), + handleAuthDismiss: vi.fn(), + withCapabilityCheck: mockWithCapabilityCheck, + unsupportedMinersInfo: { + visible: false, + unsupportedGroups: [], + totalUnsupportedCount: 0, + noneSupported: false, + }, + handleUnsupportedMinersContinue: vi.fn(), + handleUnsupportedMinersDismiss: vi.fn(), + showManageSecurityModal: false, + minerGroups: [], + handleUpdateGroup: vi.fn(), + handleSecurityModalClose: vi.fn(), + showRenameDialog: false, + handleRenameOpen: vi.fn(), + handleRenameConfirm: vi.fn(), + handleRenameDismiss: vi.fn(), + showAddToGroupModal: false, + handleAddToGroupDismiss: vi.fn(), + }); + + render( + , + ); + + fireEvent.click(screen.getByTestId("single-miner-actions-menu-button")); + fireEvent.click(screen.getByTestId("update-worker-names-popover-button")); + + const workerNameAuthProps = ( + mockAuthenticateFleetModal.mock.calls as unknown as Array< + [{ purpose?: string; open: boolean; onAuthenticated: (username: string, password: string) => void }] + > + ) + .map(([props]) => props) + .filter((props) => props.purpose === "workerNames"); + const latestWorkerNameAuthProps = workerNameAuthProps[workerNameAuthProps.length - 1]; + + await act(async () => { + latestWorkerNameAuthProps?.onAuthenticated("testuser", "testpass"); + }); + + const updateWorkerNameDialogProps = ( + mockUpdateWorkerNameDialog.mock.calls as unknown as Array< + [{ open: boolean; currentWorkerName?: string; onConfirm: (name: string) => void }] + > + ).map(([props]) => props); + const latestUpdateWorkerNameDialogProps = updateWorkerNameDialogProps[updateWorkerNameDialogProps.length - 1]; + + await act(async () => { + latestUpdateWorkerNameDialogProps?.onConfirm("worker-old"); + }); + + await waitFor(() => { + expect(mockUpdateSingleWorkerName).toHaveBeenCalledWith("test-device-123", "worker-old", "testuser", "testpass"); + }); + + expect(mockUpdateToast).toHaveBeenCalledWith(1, { + message: "Worker name unchanged", + status: "success", + }); + expect(onWorkerNameUpdated).not.toHaveBeenCalled(); + expect(onRefetchMiners).toHaveBeenCalledTimes(1); + expect(onActionComplete).toHaveBeenCalledTimes(1); + }); + + describe("needsAuthentication filtering", () => { + const allPopoverActions = [ + { + action: deviceActions.reboot, + title: "Reboot", + icon: null, + actionHandler: vi.fn(), + requiresConfirmation: true, + confirmation: { + title: "Reboot 1 miner?", + subtitle: "", + confirmAction: { title: "Reboot" }, + testId: "reboot-confirm", + }, + }, + { + action: deviceActions.blinkLEDs, + title: "Blink LEDs", + icon: null, + actionHandler: vi.fn(), + requiresConfirmation: false, + }, + { + action: settingsActions.miningPool, + title: "Edit pool", + icon: null, + actionHandler: vi.fn(), + requiresConfirmation: false, + }, + { + action: deviceActions.unpair, + title: "Unpair", + icon: null, + actionHandler: vi.fn(), + requiresConfirmation: true, + confirmation: { title: "Unpair?", subtitle: "", confirmAction: { title: "Unpair" }, testId: "unpair-confirm" }, + }, + ] as any[]; + + function renderWithActions( + props: Partial[0]> = {}, + mockOverrides: Record = {}, + ) { + mockUseMinerActions.mockReturnValue({ + currentAction: null, + popoverActions: allPopoverActions, + handleConfirmation: vi.fn(), + handleCancel: vi.fn(), + handleMiningPoolSuccess: vi.fn(), + handleMiningPoolError: vi.fn(), + showPoolSelectionPage: false, + fleetCredentials: undefined, + showManagePowerModal: false, + handleManagePowerConfirm: vi.fn(), + handleManagePowerDismiss: vi.fn(), + showFirmwareUpdateModal: false, + handleFirmwareUpdateConfirm: vi.fn(), + handleFirmwareUpdateDismiss: vi.fn(), + showCoolingModeModal: false, + coolingModeCount: 0, + currentCoolingMode: undefined, + handleCoolingModeConfirm: vi.fn(), + handleCoolingModeDismiss: vi.fn(), + showAuthenticateFleetModal: false, + authenticationPurpose: null, + showUpdatePasswordModal: false, + hasThirdPartyMiners: false, + handleFleetAuthenticated: vi.fn(), + handlePasswordConfirm: vi.fn(), + handlePasswordDismiss: vi.fn(), + handleAuthDismiss: vi.fn(), + withCapabilityCheck: mockWithCapabilityCheck, + unsupportedMinersInfo: { + visible: false, + unsupportedGroups: [], + totalUnsupportedCount: 0, + noneSupported: false, + }, + handleUnsupportedMinersContinue: vi.fn(), + handleUnsupportedMinersDismiss: vi.fn(), + showManageSecurityModal: false, + minerGroups: [], + handleUpdateGroup: vi.fn(), + handleSecurityModalClose: vi.fn(), + showRenameDialog: false, + handleRenameOpen: vi.fn(), + handleRenameConfirm: vi.fn(), + handleRenameDismiss: vi.fn(), + showAddToGroupModal: false, + handleAddToGroupDismiss: vi.fn(), + ...mockOverrides, + }); + + return render(); + } + + it("shows only Unpair when needsAuthentication is true and no minerUrl", () => { + renderWithActions({ needsAuthentication: true }); + + fireEvent.click(screen.getByTestId("single-miner-actions-menu-button")); + + expect(screen.getByText("Unpair")).toBeInTheDocument(); + expect(screen.queryByText("Reboot")).not.toBeInTheDocument(); + expect(screen.queryByText("Blink LEDs")).not.toBeInTheDocument(); + expect(screen.queryByText("Edit pool")).not.toBeInTheDocument(); + expect(screen.queryByText("View miner")).not.toBeInTheDocument(); + }); + + it("shows Unpair and View miner when needsAuthentication is true and minerUrl is set", () => { + renderWithActions({ needsAuthentication: true, minerUrl: "http://192.168.1.1" }); + + fireEvent.click(screen.getByTestId("single-miner-actions-menu-button")); + + expect(screen.getByText("View miner")).toBeInTheDocument(); + expect(screen.getByText("Unpair")).toBeInTheDocument(); + expect(screen.queryByText("Reboot")).not.toBeInTheDocument(); + expect(screen.queryByText("Blink LEDs")).not.toBeInTheDocument(); + expect(screen.queryByText("Edit pool")).not.toBeInTheDocument(); + }); + + it("does not disable the menu button when needsAuthentication is true", () => { + renderWithActions({ needsAuthentication: true }); + + const button = screen.getByTestId("single-miner-actions-menu-button"); + expect(button).not.toBeDisabled(); + }); + + it("opens Unpair confirmation dialog when Unpair is clicked for an unauthenticated miner", () => { + renderWithActions({ needsAuthentication: true }, { currentAction: deviceActions.unpair }); + + fireEvent.click(screen.getByTestId("single-miner-actions-menu-button")); + fireEvent.click(screen.getByTestId("unpair-popover-button")); + + const dialogCalls = mockBulkActionConfirmDialog.mock.calls as unknown as Array< + [{ open: boolean; actionConfirmation: { title: string } }] + >; + const unpairDialogCall = dialogCalls.find(([props]) => props.open); + expect(unpairDialogCall).toBeDefined(); + expect(unpairDialogCall![0].actionConfirmation.title).toBe("Unpair?"); + }); + + it("preserves pending confirmation dialog when auth status hides the triggering action", () => { + renderWithActions({ needsAuthentication: true }, { currentAction: deviceActions.reboot }); + + const dialogCalls = mockBulkActionConfirmDialog.mock.calls as unknown as Array< + [{ open: boolean; actionConfirmation: { title: string } }] + >; + const rebootDialogCall = dialogCalls.find(([props]) => props.actionConfirmation?.title?.includes("Reboot")); + expect(rebootDialogCall).toBeDefined(); + }); + + it("shows all actions when needsAuthentication is false", () => { + renderWithActions({ needsAuthentication: false }); + + fireEvent.click(screen.getByTestId("single-miner-actions-menu-button")); + + expect(screen.getByText("Reboot")).toBeInTheDocument(); + expect(screen.getByText("Blink LEDs")).toBeInTheDocument(); + expect(screen.getByText("Edit pool")).toBeInTheDocument(); + expect(screen.getByText("Unpair")).toBeInTheDocument(); + }); + }); + + it("shows an error toast when a streamed worker-name update reports an immediate failure", async () => { + mockUpdateSingleWorkerName.mockResolvedValue({ + updatedCount: 0, + unchangedCount: 0, + failedCount: 1, + batchIdentifier: "batch-1", + }); + + const onActionComplete = vi.fn(); + const onRefetchMiners = vi.fn(); + const onWorkerNameUpdated = vi.fn(); + + mockUseMinerActions.mockReturnValue({ + currentAction: null, + popoverActions: [ + { + action: settingsActions.miningPool, + title: "Edit pool", + icon: null, + actionHandler: vi.fn(), + requiresConfirmation: false, + }, + ] as any[], + handleConfirmation: vi.fn(), + handleCancel: vi.fn(), + handleMiningPoolSuccess: vi.fn(), + handleMiningPoolError: vi.fn(), + showPoolSelectionPage: false, + fleetCredentials: undefined, + showManagePowerModal: false, + handleManagePowerConfirm: vi.fn(), + handleManagePowerDismiss: vi.fn(), + showFirmwareUpdateModal: false, + handleFirmwareUpdateConfirm: vi.fn(), + handleFirmwareUpdateDismiss: vi.fn(), + showCoolingModeModal: false, + coolingModeCount: 0, + currentCoolingMode: undefined, + handleCoolingModeConfirm: vi.fn(), + handleCoolingModeDismiss: vi.fn(), + showAuthenticateFleetModal: false, + authenticationPurpose: null, + showUpdatePasswordModal: false, + hasThirdPartyMiners: false, + handleFleetAuthenticated: vi.fn(), + handlePasswordConfirm: vi.fn(), + handlePasswordDismiss: vi.fn(), + handleAuthDismiss: vi.fn(), + withCapabilityCheck: mockWithCapabilityCheck, + unsupportedMinersInfo: { + visible: false, + unsupportedGroups: [], + totalUnsupportedCount: 0, + noneSupported: false, + }, + handleUnsupportedMinersContinue: vi.fn(), + handleUnsupportedMinersDismiss: vi.fn(), + showManageSecurityModal: false, + minerGroups: [], + handleUpdateGroup: vi.fn(), + handleSecurityModalClose: vi.fn(), + showRenameDialog: false, + handleRenameOpen: vi.fn(), + handleRenameConfirm: vi.fn(), + handleRenameDismiss: vi.fn(), + showAddToGroupModal: false, + handleAddToGroupDismiss: vi.fn(), + }); + + render( + , + ); + + fireEvent.click(screen.getByTestId("single-miner-actions-menu-button")); + fireEvent.click(screen.getByTestId("update-worker-names-popover-button")); + + const workerNameAuthProps = ( + mockAuthenticateFleetModal.mock.calls as unknown as Array< + [{ purpose?: string; open: boolean; onAuthenticated: (username: string, password: string) => void }] + > + ) + .map(([props]) => props) + .filter((props) => props.purpose === "workerNames"); + const latestWorkerNameAuthProps = workerNameAuthProps[workerNameAuthProps.length - 1]; + + await act(async () => { + latestWorkerNameAuthProps?.onAuthenticated("testuser", "testpass"); + }); + + const updateWorkerNameDialogProps = ( + mockUpdateWorkerNameDialog.mock.calls as unknown as Array< + [{ open: boolean; currentWorkerName?: string; onConfirm: (name: string) => void }] + > + ).map(([props]) => props); + const latestUpdateWorkerNameDialogProps = updateWorkerNameDialogProps[updateWorkerNameDialogProps.length - 1]; + + await act(async () => { + latestUpdateWorkerNameDialogProps?.onConfirm("worker-new"); + }); + + await waitFor(() => { + expect(mockUpdateSingleWorkerName).toHaveBeenCalledWith("test-device-123", "worker-new", "testuser", "testpass"); + }); + + expect(mockUpdateToast).toHaveBeenCalledWith(1, { + message: "Failed to update worker name", + status: "error", + }); + expect(onWorkerNameUpdated).not.toHaveBeenCalled(); + expect(onRefetchMiners).not.toHaveBeenCalled(); + expect(onActionComplete).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/SingleMinerActionsMenu.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/SingleMinerActionsMenu.tsx new file mode 100644 index 000000000..c2ac29d97 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/SingleMinerActionsMenu.tsx @@ -0,0 +1,710 @@ +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import PoolSelectionPageWrapper from "../ActionBar/SettingsWidget/PoolSelectionPage"; +import BulkActionConfirmDialog from "../BulkActions/BulkActionConfirmDialog"; +import { BulkAction, UnsupportedMinersInfo } from "../BulkActions/types"; +import UnsupportedMinersModal from "../BulkActions/UnsupportedMinersModal"; +import { insertActionAfter, insertActionBefore } from "./actionMenuUtils"; +import AddToGroupModal from "./AddToGroupModal"; +import { deviceActions, groupActions, performanceActions, settingsActions, SupportedAction } from "./constants"; +import CoolingModeModal from "./CoolingModeModal"; +import FirmwareUpdateModal from "./FirmwareUpdateModal"; +import ManagePowerModal from "./ManagePowerModal"; +import { ManageSecurityModal, UpdateMinerPasswordModal } from "./ManageSecurity"; +import RenameMinerDialog from "./RenameMinerDialog"; +import UpdateWorkerNameDialog from "./UpdateWorkerNameDialog"; +import { type SecurityActionsProps } from "./useManageSecurityFlow"; +import { type MinerSelection, useMinerActions } from "./useMinerActions"; +import { waitForWorkerNameBatchResult } from "./waitForWorkerNameBatchResult"; +import { CoolingMode } from "@/protoFleet/api/generated/common/v1/cooling_pb"; +import type { + MinerStateSnapshot, + UpdateWorkerNamesResponse, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { PerformanceMode } from "@/protoFleet/api/generated/minercommand/v1/command_pb"; +import type { DeviceStatus } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { useMinerCommand } from "@/protoFleet/api/useMinerCommand"; +import useUpdateWorkerNames from "@/protoFleet/api/useUpdateWorkerNames"; +import AuthenticateFleetModal from "@/protoFleet/features/auth/components/AuthenticateFleetModal"; +import { useBatchOperations } from "@/protoFleet/features/fleetManagement/hooks/useBatchOperations"; +import { ArrowRight, Edit, Ellipsis, MiningPools } from "@/shared/assets/icons"; +import { iconSizes } from "@/shared/assets/icons/constants"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import Divider from "@/shared/components/Divider"; +import Popover, { popoverSizes } from "@/shared/components/Popover"; +import { PopoverProvider, usePopover } from "@/shared/components/Popover"; +import Row from "@/shared/components/Row"; +import { positions } from "@/shared/constants"; +import { pushToast, removeToast, STATUSES as TOAST_STATUSES, updateToast } from "@/shared/features/toaster"; +import { useClickOutside } from "@/shared/hooks/useClickOutside"; + +type SingleMinerAction = SupportedAction | "viewMiner"; + +const unauthenticatedActions = new Set([deviceActions.unpair, "viewMiner"]); + +interface SingleMinerActionsMenuProps { + deviceIdentifier: string; + minerUrl?: string; + deviceStatus?: DeviceStatus; + minerName?: string; + workerName?: string; + onActionStart?: () => void; + onActionComplete?: () => void; + needsAuthentication?: boolean; + miners?: Record; + onRefetchMiners?: () => void; + onWorkerNameUpdated?: (deviceIdentifier: string, workerName: string) => void; +} + +const SingleMinerActionsMenu = ({ + deviceIdentifier, + minerUrl, + deviceStatus, + minerName, + workerName, + onActionStart, + onActionComplete, + needsAuthentication = false, + miners, + onRefetchMiners, + onWorkerNameUpdated, +}: SingleMinerActionsMenuProps) => { + const { startBatchOperation, completeBatchOperation, removeDevicesFromBatch } = useBatchOperations(); + const { streamCommandBatchUpdates } = useMinerCommand(); + const { updateSingleWorkerName } = useUpdateWorkerNames(); + const selectedMiners = useMemo(() => [{ deviceIdentifier, deviceStatus }], [deviceIdentifier, deviceStatus]); + const [showWorkerNameAuthenticateModal, setShowWorkerNameAuthenticateModal] = useState(false); + const [showUpdateWorkerNameDialog, setShowUpdateWorkerNameDialog] = useState(false); + const workerNameCredentialsRef = useRef<{ username: string; password: string } | undefined>(undefined); + + const { + currentAction, + popoverActions, + handleConfirmation, + handleCancel, + handleMiningPoolSuccess, + handleMiningPoolError, + showPoolSelectionPage, + fleetCredentials, + showManagePowerModal, + handleManagePowerConfirm, + handleManagePowerDismiss, + showFirmwareUpdateModal, + handleFirmwareUpdateConfirm, + handleFirmwareUpdateDismiss, + showCoolingModeModal, + coolingModeCount, + currentCoolingMode, + handleCoolingModeConfirm, + handleCoolingModeDismiss, + showAuthenticateFleetModal, + authenticationPurpose, + showUpdatePasswordModal, + hasThirdPartyMiners, + handleFleetAuthenticated, + handlePasswordConfirm, + handlePasswordDismiss, + handleAuthDismiss, + withCapabilityCheck, + unsupportedMinersInfo, + handleUnsupportedMinersContinue, + handleUnsupportedMinersDismiss, + showManageSecurityModal, + minerGroups, + handleUpdateGroup, + handleSecurityModalClose, + showRenameDialog, + handleRenameOpen, + handleRenameConfirm, + handleRenameDismiss, + showAddToGroupModal, + handleAddToGroupDismiss, + } = useMinerActions({ + selectedMiners, + // Single-miner actions always target a specific device, never "all devices" + selectionMode: "subset", + startBatchOperation, + completeBatchOperation, + removeDevicesFromBatch, + miners, + onRefetchMiners, + onActionStart, + onActionComplete, + }); + + const handleViewMiner = useCallback(() => { + if (minerUrl) { + window.open(minerUrl, "_blank", "noopener,noreferrer"); + } + }, [minerUrl]); + + const resetWorkerNameFlow = useCallback(() => { + setShowWorkerNameAuthenticateModal(false); + setShowUpdateWorkerNameDialog(false); + workerNameCredentialsRef.current = undefined; + }, []); + + const handleUpdateWorkerNameDismiss = useCallback(() => { + resetWorkerNameFlow(); + onActionComplete?.(); + }, [onActionComplete, resetWorkerNameFlow]); + + const handleUpdateWorkerNameOpen = useCallback(() => { + setShowWorkerNameAuthenticateModal(true); + }, []); + + const handleUpdateWorkerNameAuthenticated = useCallback((username: string, password: string) => { + workerNameCredentialsRef.current = { username, password }; + setShowWorkerNameAuthenticateModal(false); + setShowUpdateWorkerNameDialog(true); + }, []); + + const handleUpdateWorkerNameAction = useCallback(() => { + onActionStart?.(); + void withCapabilityCheck(settingsActions.updateWorkerNames, () => { + handleUpdateWorkerNameOpen(); + }); + }, [handleUpdateWorkerNameOpen, onActionStart, withCapabilityCheck]); + + const showWorkerNameUpdatedToast = useCallback( + (toastId: number, name: string) => { + onWorkerNameUpdated?.(deviceIdentifier, name); + onRefetchMiners?.(); + updateToast(toastId, { + message: "Worker name updated", + status: TOAST_STATUSES.success, + }); + }, + [deviceIdentifier, onRefetchMiners, onWorkerNameUpdated], + ); + + const showWorkerNameErrorToast = useCallback((toastId: number) => { + updateToast(toastId, { + message: "Failed to update worker name", + status: TOAST_STATUSES.error, + }); + }, []); + + const showWorkerNameUnchangedToast = useCallback( + (toastId: number) => { + onRefetchMiners?.(); + updateToast(toastId, { + message: "Worker name unchanged", + status: TOAST_STATUSES.success, + }); + }, + [onRefetchMiners], + ); + + const handleDirectWorkerNameResponse = useCallback( + (toastId: number, name: string, response: UpdateWorkerNamesResponse) => { + if (response.failedCount > 0) { + showWorkerNameErrorToast(toastId); + return; + } + + if (response.updatedCount > 0) { + showWorkerNameUpdatedToast(toastId, name); + return; + } + + if (response.unchangedCount > 0) { + showWorkerNameUnchangedToast(toastId); + return; + } + + removeToast(toastId); + }, + [showWorkerNameErrorToast, showWorkerNameUnchangedToast, showWorkerNameUpdatedToast], + ); + + const handleStreamedWorkerNameResponse = useCallback( + ( + toastId: number, + name: string, + response: UpdateWorkerNamesResponse, + batchResult: Awaited>, + ) => { + if (batchResult.streamFailed || response.failedCount > 0 || batchResult.failedCount > 0) { + showWorkerNameErrorToast(toastId); + return; + } + + if (batchResult.successCount > 0) { + showWorkerNameUpdatedToast(toastId, name); + return; + } + + if (response.unchangedCount > 0) { + showWorkerNameUnchangedToast(toastId); + return; + } + + removeToast(toastId); + }, + [showWorkerNameErrorToast, showWorkerNameUnchangedToast, showWorkerNameUpdatedToast], + ); + + const handleUpdateWorkerNameConfirm = useCallback( + async (name: string) => { + const workerNameCredentials = workerNameCredentialsRef.current; + + if (!workerNameCredentials) { + return; + } + + setShowUpdateWorkerNameDialog(false); + + const toastId = pushToast({ + message: "Updating worker name", + status: TOAST_STATUSES.loading, + longRunning: true, + }); + + try { + const response = await updateSingleWorkerName( + deviceIdentifier, + name, + workerNameCredentials.username, + workerNameCredentials.password, + ); + + if (response.batchIdentifier) { + startBatchOperation({ + batchIdentifier: response.batchIdentifier, + action: settingsActions.updateWorkerNames, + deviceIdentifiers: [deviceIdentifier], + }); + + try { + const batchResult = await waitForWorkerNameBatchResult(streamCommandBatchUpdates, response.batchIdentifier); + handleStreamedWorkerNameResponse(toastId, name, response, batchResult); + } finally { + completeBatchOperation(response.batchIdentifier); + } + } else { + handleDirectWorkerNameResponse(toastId, name, response); + } + } catch { + showWorkerNameErrorToast(toastId); + } finally { + resetWorkerNameFlow(); + onActionComplete?.(); + } + }, + [ + completeBatchOperation, + deviceIdentifier, + handleDirectWorkerNameResponse, + handleStreamedWorkerNameResponse, + onActionComplete, + resetWorkerNameFlow, + showWorkerNameErrorToast, + startBatchOperation, + streamCommandBatchUpdates, + updateSingleWorkerName, + ], + ); + + const actionsWithSingleNameFlows = useMemo(() => { + const viewMinerAction: BulkAction | null = minerUrl + ? { + action: "viewMiner", + title: "View miner", + icon: , + actionHandler: handleViewMiner, + requiresConfirmation: false, + showGroupDivider: true, + } + : null; + + const renameAction: BulkAction = { + action: settingsActions.rename, + title: "Rename", + icon: , + actionHandler: handleRenameOpen, + requiresConfirmation: false, + }; + + const updateWorkerNameAction: BulkAction = { + action: settingsActions.updateWorkerNames, + title: "Update worker name", + icon: , + actionHandler: handleUpdateWorkerNameAction, + requiresConfirmation: false, + }; + + const actions = insertActionAfter(popoverActions, settingsActions.miningPool, updateWorkerNameAction); + const actionsWithRenameBeforeGroup = insertActionBefore(actions, groupActions.addToGroup, renameAction); + + if (actionsWithRenameBeforeGroup !== actions) { + return viewMinerAction ? [viewMinerAction, ...actionsWithRenameBeforeGroup] : actionsWithRenameBeforeGroup; + } + + const actionsWithRenameBeforeSecurity = insertActionBefore(actions, settingsActions.security, { + ...renameAction, + showGroupDivider: true, + }); + + if (actionsWithRenameBeforeSecurity !== actions) { + return viewMinerAction ? [viewMinerAction, ...actionsWithRenameBeforeSecurity] : actionsWithRenameBeforeSecurity; + } + + return viewMinerAction ? [viewMinerAction, ...actions, renameAction] : [...actions, renameAction]; + }, [handleRenameOpen, handleUpdateWorkerNameAction, handleViewMiner, minerUrl, popoverActions]); + + const visibleActions = useMemo( + () => + needsAuthentication + ? actionsWithSingleNameFlows.filter((a) => unauthenticatedActions.has(a.action)) + : actionsWithSingleNameFlows, + [actionsWithSingleNameFlows, needsAuthentication], + ); + + const [isOpen, setIsOpen] = useState(false); + const [showWarnDialog, setShowWarnDialog] = useState(false); + + const onClickOutside = useCallback(() => { + setIsOpen(false); + }, []); + + const handleAction = (action: BulkAction) => { + setIsOpen(false); + if (action.requiresConfirmation) { + setShowWarnDialog(true); + } + action.actionHandler(); + }; + + const handleConfirmationClick = () => { + setShowWarnDialog(false); + handleConfirmation(); + }; + + const handleCancelClick = () => { + setShowWarnDialog(false); + handleCancel(); + }; + + // Prevent confirmation dialog flash when continuing from unsupported miners modal + const handleUnsupportedMinersContinueWithReset = useCallback(() => { + setShowWarnDialog(false); + handleUnsupportedMinersContinue(); + }, [handleUnsupportedMinersContinue]); + + return ( + + + + ); +}; + +type SingleMinerActionsMenuInnerProps = { + isOpen: boolean; + setIsOpen: (value: boolean | ((prev: boolean) => boolean)) => void; + showWarnDialog: boolean; + currentAction: SupportedAction | null; + popoverActions: BulkAction[]; + confirmationActions: BulkAction[]; + onClickOutside: () => void; + handleAction: (action: BulkAction) => void; + handleConfirmationClick: () => void; + handleCancelClick: () => void; + selectedMiners: MinerSelection[]; + showPoolSelectionPage: boolean; + fleetCredentials: { username: string; password: string } | undefined; + handleMiningPoolSuccess: (batchIdentifier: string) => void; + handleMiningPoolError: (error: string) => void; + handleCancel: () => void; + showManagePowerModal: boolean; + handleManagePowerConfirm: (performanceMode: PerformanceMode) => void; + handleManagePowerDismiss: () => void; + showFirmwareUpdateModal: boolean; + handleFirmwareUpdateConfirm: (firmwareFileId: string) => void; + handleFirmwareUpdateDismiss: () => void; + showCoolingModeModal: boolean; + coolingModeCount: number; + currentCoolingMode: CoolingMode | undefined; + handleCoolingModeConfirm: (coolingMode: CoolingMode) => void; + handleCoolingModeDismiss: () => void; + unsupportedMinersInfo: UnsupportedMinersInfo; + handleUnsupportedMinersContinue: () => void; + handleUnsupportedMinersDismiss: () => void; + deviceIdentifier: string; + minerName?: string; + workerName?: string; + showRenameDialog: boolean; + handleRenameConfirm: (name: string) => void; + handleRenameDismiss: () => void; + showWorkerNameAuthenticateModal: boolean; + handleUpdateWorkerNameAuthenticated: (username: string, password: string) => void; + showUpdateWorkerNameDialog: boolean; + handleUpdateWorkerNameConfirm: (name: string) => void; + handleUpdateWorkerNameDismiss: () => void; + showAddToGroupModal: boolean; + handleAddToGroupDismiss: () => void; +} & SecurityActionsProps; + +const SingleMinerActionsMenuInner = ({ + isOpen, + setIsOpen, + showWarnDialog, + currentAction, + popoverActions, + confirmationActions, + onClickOutside, + handleAction, + handleConfirmationClick, + handleCancelClick, + selectedMiners, + showPoolSelectionPage, + fleetCredentials, + handleMiningPoolSuccess, + handleMiningPoolError, + handleCancel, + showManagePowerModal, + handleManagePowerConfirm, + handleManagePowerDismiss, + showFirmwareUpdateModal, + handleFirmwareUpdateConfirm, + handleFirmwareUpdateDismiss, + showCoolingModeModal, + coolingModeCount, + currentCoolingMode, + handleCoolingModeConfirm, + handleCoolingModeDismiss, + showAuthenticateFleetModal, + authenticationPurpose, + showUpdatePasswordModal, + hasThirdPartyMiners, + handleFleetAuthenticated, + handlePasswordConfirm, + handlePasswordDismiss, + handleAuthDismiss, + unsupportedMinersInfo, + handleUnsupportedMinersContinue, + handleUnsupportedMinersDismiss, + showManageSecurityModal, + minerGroups, + handleUpdateGroup, + handleSecurityModalClose, + deviceIdentifier, + minerName, + workerName, + showRenameDialog, + handleRenameConfirm, + handleRenameDismiss, + showWorkerNameAuthenticateModal, + handleUpdateWorkerNameAuthenticated, + showUpdateWorkerNameDialog, + handleUpdateWorkerNameConfirm, + handleUpdateWorkerNameDismiss, + showAddToGroupModal, + handleAddToGroupDismiss, +}: SingleMinerActionsMenuInnerProps) => { + const { triggerRef, setPopoverRenderMode } = usePopover(); + useEffect(() => { + setPopoverRenderMode("portal-fixed"); + }, [setPopoverRenderMode]); + + useClickOutside({ + ref: triggerRef, + onClickOutside, + ignoreSelectors: [".popover-content"], + }); + + return ( +
+
+ ); +}; + +export default SingleMinerActionsMenu; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/UpdateWorkerNameDialog.test.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/UpdateWorkerNameDialog.test.tsx new file mode 100644 index 000000000..9240a7158 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/UpdateWorkerNameDialog.test.tsx @@ -0,0 +1,158 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import UpdateWorkerNameDialog from "./UpdateWorkerNameDialog"; +import Input from "@/shared/components/Input"; + +vi.mock("@/shared/components/Modal/Modal", () => ({ + default: vi.fn( + ({ + open, + children, + buttons, + onDismiss, + title, + }: { + open: boolean; + children: React.ReactNode; + buttons?: { text: string; onClick: () => void; variant?: string; dismissModalOnClick?: boolean }[]; + onDismiss: () => void; + title: string; + }) => { + if (!open) return null; + return ( +
+

{title}

+ {children} + + {buttons?.map((button, index) => ( + + ))} +
+ ); + }, + ), +})); + +vi.mock("@/shared/components/Dialog", () => ({ + default: vi.fn(({ open, title, buttons }) => { + if (!open) return null; + return ( +
+

{title}

+ {buttons?.map((button: { text: string; onClick: () => void }, index: number) => ( + + ))} +
+ ); + }), +})); + +vi.mock("@/shared/components/Input", () => ({ + default: vi.fn(({ id, label, initValue, onChange, onKeyDown, testId }) => ( +
+ + onChange(e.target.value)} + onKeyDown={(e) => onKeyDown?.(e.key)} + data-testid={testId ?? id} + /> +
+ )), +})); + +vi.mock("@/shared/components/NamePreview", () => ({ + default: vi.fn(({ currentName, newName }: { currentName: string; newName: string }) => ( +
+ )), +})); + +describe("UpdateWorkerNameDialog", () => { + const mockOnConfirm = vi.fn(); + const mockOnDismiss = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("calls onConfirm with a trimmed worker name when Save is clicked", () => { + render( + , + ); + + fireEvent.change(screen.getByTestId("update-worker-name-input"), { target: { value: " worker-new " } }); + fireEvent.click(screen.getByTestId("modal-button-0")); + + expect(mockOnConfirm).toHaveBeenCalledWith("worker-new"); + }); + + it("submits the current worker name when no-change confirmation is accepted", () => { + render( + , + ); + + fireEvent.click(screen.getByTestId("modal-button-0")); + + expect(screen.getByTestId("update-worker-name-no-changes-dialog")).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("dialog-button-1")); + + expect(mockOnConfirm).toHaveBeenCalledWith("worker-old"); + expect(mockOnDismiss).not.toHaveBeenCalled(); + }); + + it("keeps editing when no-change confirmation is accepted for an empty worker name", () => { + render( + , + ); + + fireEvent.click(screen.getByTestId("modal-button-0")); + + expect(screen.getByTestId("update-worker-name-no-changes-dialog")).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("dialog-button-1")); + + expect(mockOnConfirm).not.toHaveBeenCalled(); + expect(screen.getByTestId("update-worker-name-modal")).toBeInTheDocument(); + expect(screen.queryByTestId("update-worker-name-no-changes-dialog")).not.toBeInTheDocument(); + }); + + it("passes maxLength of 100 to the input", () => { + render( + , + ); + + const [firstCallProps] = vi.mocked(Input).mock.calls[0]; + expect(firstCallProps.maxLength).toBe(100); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/UpdateWorkerNameDialog.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/UpdateWorkerNameDialog.tsx new file mode 100644 index 000000000..183bb3572 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/UpdateWorkerNameDialog.tsx @@ -0,0 +1,113 @@ +import { useCallback, useState } from "react"; + +import { variants } from "@/shared/components/Button"; +import Dialog from "@/shared/components/Dialog"; +import Input from "@/shared/components/Input"; +import Modal from "@/shared/components/Modal/Modal"; +import NamePreview from "@/shared/components/NamePreview"; +import { INACTIVE_PLACEHOLDER } from "@/shared/constants"; + +const maxWorkerNameLength = 100; + +interface UpdateWorkerNameDialogProps { + open: boolean; + currentWorkerName?: string; + onConfirm: (name: string) => void; + onDismiss: () => void; +} + +const UpdateWorkerNameDialog = ({ open, currentWorkerName, onConfirm, onDismiss }: UpdateWorkerNameDialogProps) => { + const currentName = currentWorkerName?.trim() ?? ""; + const [inputValue, setInputValue] = useState(currentName); + const [showNoChangesWarning, setShowNoChangesWarning] = useState(false); + + const handleChange = useCallback((value: string) => { + setInputValue(value); + }, []); + + const handleSave = useCallback(() => { + const trimmed = inputValue.trim(); + + if (trimmed === "" || trimmed === currentName) { + setShowNoChangesWarning(true); + return; + } + + onConfirm(trimmed); + }, [currentName, inputValue, onConfirm]); + + const handleContinueWithoutChanges = useCallback(() => { + setShowNoChangesWarning(false); + if (currentName === "") { + return; + } + + onConfirm(currentName); + }, [currentName, onConfirm]); + + if (showNoChangesWarning) { + return ( + setShowNoChangesWarning(false), + }, + { + text: "Yes, continue", + variant: variants.primary, + onClick: handleContinueWithoutChanges, + }, + ]} + /> + ); + } + + return ( + +
+ { + if (key === "Enter") handleSave(); + }} + maxLength={maxWorkerNameLength} + autoFocus + testId="update-worker-name-input" + /> +

+ This updates the worker name stored in Fleet and reapplies the miner's current pool settings. +

+
+ +
+
+
+ ); +}; + +export default UpdateWorkerNameDialog; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/actionMenuUtils.ts b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/actionMenuUtils.ts new file mode 100644 index 000000000..16559db23 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/actionMenuUtils.ts @@ -0,0 +1,29 @@ +import type { BulkAction } from "../BulkActions/types"; + +export function insertActionAfter( + actions: BulkAction[], + targetAction: TAction, + insertedAction: BulkAction, +): BulkAction[] { + const targetIndex = actions.findIndex((action) => action.action === targetAction); + + if (targetIndex === -1) { + return actions; + } + + return [...actions.slice(0, targetIndex + 1), insertedAction, ...actions.slice(targetIndex + 1)]; +} + +export function insertActionBefore( + actions: BulkAction[], + targetAction: TAction, + insertedAction: BulkAction, +): BulkAction[] { + const targetIndex = actions.findIndex((action) => action.action === targetAction); + + if (targetIndex === -1) { + return actions; + } + + return [...actions.slice(0, targetIndex), insertedAction, ...actions.slice(targetIndex)]; +} diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/bulkRenameDefinitions.test.ts b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/bulkRenameDefinitions.test.ts new file mode 100644 index 000000000..7d975f2d1 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/bulkRenameDefinitions.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from "vitest"; +import { + bulkRenameModes, + type BulkRenamePreviewMiner, + bulkRenamePropertyIds, + bulkRenameSeparatorIds, + createDefaultBulkRenamePreferences, + getEnabledBulkRenameProperties, + hasUniquenessGuaranteeingProperty, + normalizeBulkRenamePreferences, + reorderBulkRenameProperties, + shouldWarnAboutBulkRenameDuplicates, +} from "./bulkRenameDefinitions"; +import { customPropertyTypes, fixedStringSections } from "./RenameOptionsModals/types"; + +const basePreviewMiner: BulkRenamePreviewMiner = { + counterIndex: 0, + deviceIdentifier: "device-1", + currentName: "Proto Rig", + storedName: "Proto Rig", + macAddress: "AA:BB:CC:DD:EE:FF", + serialNumber: "SER123456", + minerName: "Proto Rig", + model: "S21 XP", + manufacturer: "Bitmain", + workerName: "worker-01", + rackLabel: "Rack-A1", + rackPosition: "12", +}; + +const legacyHiddenPropertyId = "fixed-location"; + +describe("bulkRenameDefinitions", () => { + it("normalizes persisted preferences, drops hidden properties, and appends known ones", () => { + const normalized = normalizeBulkRenamePreferences({ + separator: bulkRenameSeparatorIds.underscore, + properties: [ + { + id: bulkRenamePropertyIds.fixedSerialNumber, + enabled: true, + options: { + characterCount: 4, + stringSection: fixedStringSections.last, + }, + }, + { + id: legacyHiddenPropertyId as never, + enabled: true, + options: { + characterCount: 4, + stringSection: fixedStringSections.last, + }, + }, + ], + }); + + expect(normalized.separator).toBe(bulkRenameSeparatorIds.underscore); + expect(normalized.properties[0].id).toBe(bulkRenamePropertyIds.fixedSerialNumber); + expect(normalized.properties.find((property) => String(property.id) === legacyHiddenPropertyId)).toBeUndefined(); + expect(normalized.properties).toHaveLength(8); + }); + + it("builds rename defaults with rack properties", () => { + const preferences = createDefaultBulkRenamePreferences(); + + expect(preferences.properties.map((property) => property.id)).toEqual([ + bulkRenamePropertyIds.fixedMacAddress, + bulkRenamePropertyIds.fixedSerialNumber, + bulkRenamePropertyIds.fixedWorkerName, + bulkRenamePropertyIds.fixedModel, + bulkRenamePropertyIds.fixedManufacturer, + bulkRenamePropertyIds.qualifierRack, + bulkRenamePropertyIds.qualifierRackPosition, + bulkRenamePropertyIds.custom, + ]); + }); + + it("builds worker-name defaults with rack properties and miner name", () => { + const preferences = createDefaultBulkRenamePreferences(bulkRenameModes.worker); + + expect(preferences.properties.map((property) => property.id)).toEqual([ + bulkRenamePropertyIds.fixedMacAddress, + bulkRenamePropertyIds.fixedSerialNumber, + bulkRenamePropertyIds.fixedMinerName, + bulkRenamePropertyIds.fixedModel, + bulkRenamePropertyIds.fixedManufacturer, + bulkRenamePropertyIds.qualifierRack, + bulkRenamePropertyIds.qualifierRackPosition, + bulkRenamePropertyIds.custom, + ]); + }); + + it("tracks uniqueness-guaranteeing properties and reorder operations", () => { + const preferences = createDefaultBulkRenamePreferences(); + preferences.properties = preferences.properties.map((property) => { + if (property.id === bulkRenamePropertyIds.custom) { + return { + ...property, + enabled: true, + options: { + ...property.options, + type: customPropertyTypes.stringOnly, + stringValue: "Fleet", + }, + }; + } + + if (property.id === bulkRenamePropertyIds.fixedMacAddress) { + return { ...property, enabled: true }; + } + + return property; + }); + + expect(getEnabledBulkRenameProperties(preferences)).toHaveLength(2); + expect(hasUniquenessGuaranteeingProperty(preferences, [basePreviewMiner])).toBe(true); + + const reordered = reorderBulkRenameProperties( + preferences, + bulkRenamePropertyIds.custom, + bulkRenamePropertyIds.fixedMacAddress, + ); + + expect(reordered.properties[0].id).toBe(bulkRenamePropertyIds.custom); + }); + + it("does not treat truncated unique fixed values as uniqueness-guaranteeing", () => { + const preferences = createDefaultBulkRenamePreferences(); + preferences.properties = preferences.properties.map((property) => { + if (property.id === bulkRenamePropertyIds.fixedMacAddress) { + return { + ...property, + enabled: true, + options: { + characterCount: 4, + stringSection: fixedStringSections.last, + }, + }; + } + + return property; + }); + + expect(hasUniquenessGuaranteeingProperty(preferences, [basePreviewMiner])).toBe(false); + }); + + it("does not treat full-length unique fixed values as guaranteed when some miners are missing them", () => { + const preferences = createDefaultBulkRenamePreferences(); + preferences.properties = preferences.properties.map((property) => { + if (property.id === bulkRenamePropertyIds.fixedSerialNumber) { + return { + ...property, + enabled: true, + options: { + characterCount: "all", + stringSection: fixedStringSections.last, + }, + }; + } + + return property; + }); + + expect( + hasUniquenessGuaranteeingProperty(preferences, [ + basePreviewMiner, + { + ...basePreviewMiner, + deviceIdentifier: "device-2", + serialNumber: "", + }, + ]), + ).toBe(false); + }); + + it("does not treat counter-based custom properties as unique when counter start is missing", () => { + const preferences = createDefaultBulkRenamePreferences(); + preferences.properties = preferences.properties.map((property) => { + if (property.id === bulkRenamePropertyIds.custom) { + return { + ...property, + enabled: true, + options: { + ...property.options, + type: customPropertyTypes.counterOnly, + counterStart: undefined, + }, + }; + } + + return property; + }); + + expect(hasUniquenessGuaranteeingProperty(preferences, [basePreviewMiner])).toBe(false); + }); + + it("skips duplicate-name warnings for single-miner renames", () => { + const preferences = createDefaultBulkRenamePreferences(); + + expect(shouldWarnAboutBulkRenameDuplicates(1, preferences, [basePreviewMiner])).toBe(false); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/bulkRenameDefinitions.ts b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/bulkRenameDefinitions.ts new file mode 100644 index 000000000..4776a6124 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/bulkRenameDefinitions.ts @@ -0,0 +1,376 @@ +import { fixedCharacterCountAll } from "./RenameOptionsModals/constants"; +import { + type CustomPropertyOptionsValues, + customPropertyTypes, + fixedStringSections, + type FixedValueOptionsValues, + type QualifierOptionsValues, +} from "./RenameOptionsModals/types"; +import { FixedValueType, QualifierType } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +export const bulkRenameSeparatorIds = { + dash: "dash", + underscore: "underscore", + period: "period", + none: "none", +} as const; + +export type BulkRenameSeparatorId = (typeof bulkRenameSeparatorIds)[keyof typeof bulkRenameSeparatorIds]; + +export const bulkRenameModes = { + rename: "rename", + worker: "worker", +} as const; + +export type BulkRenameMode = (typeof bulkRenameModes)[keyof typeof bulkRenameModes]; + +export const bulkRenameSeparators: Record< + BulkRenameSeparatorId, + { + label: string; + value: string; + } +> = { + [bulkRenameSeparatorIds.dash]: { label: "Dash ( - )", value: "-" }, + [bulkRenameSeparatorIds.underscore]: { label: "Underscore ( _ )", value: "_" }, + [bulkRenameSeparatorIds.period]: { label: "Period ( . )", value: "." }, + [bulkRenameSeparatorIds.none]: { label: "None", value: "" }, +}; + +type PropertyKind = "custom" | "fixed" | "qualifier"; +type QualifierPropertySpec = readonly [string, string, QualifierType]; +type FixedPropertySpec = readonly [string, string, FixedValueType, boolean]; + +type KebabCase = Value extends `${infer First}${infer Rest}` + ? Rest extends Uncapitalize + ? `${Lowercase}${KebabCase}` + : `${Lowercase}-${KebabCase}` + : Value; + +export type BulkRenamePropertyOptions = CustomPropertyOptionsValues | FixedValueOptionsValues | QualifierOptionsValues; + +export interface BulkRenamePropertyState { + id: BulkRenamePropertyId; + enabled: boolean; + options: BulkRenamePropertyOptions; +} + +export interface BulkRenamePreferences { + separator: BulkRenameSeparatorId; + properties: BulkRenamePropertyState[]; +} + +export interface BulkRenamePropertyDefinition { + id: BulkRenamePropertyId; + label: string; + kind: PropertyKind; + defaultOptions: BulkRenamePropertyOptions; + guaranteesUniqueness?: boolean; + fixedValueType?: FixedValueType; + qualifierType?: QualifierType; +} + +export interface BulkRenamePreviewMiner { + counterIndex: number; + deviceIdentifier: string; + currentName: string; + storedName: string; + macAddress: string; + serialNumber: string; + minerName: string; + model: string; + manufacturer: string; + workerName: string; + rackLabel: string; + rackPosition: string; +} + +const hasNonEmptyUniquenessValue = (property: BulkRenamePropertyState, miner: BulkRenamePreviewMiner): boolean => { + switch (property.id) { + case bulkRenamePropertyIds.fixedMacAddress: + return miner.macAddress.trim() !== ""; + case bulkRenamePropertyIds.fixedSerialNumber: + return miner.serialNumber.trim() !== ""; + default: + return false; + } +}; + +export interface BulkRenamePropertyPreview { + previewName: string; + highlightedText?: string; + highlightStartIndex?: number; +} + +const defaultFixedValueOptions = { + characterCount: fixedCharacterCountAll, + stringSection: fixedStringSections.last, +} satisfies FixedValueOptionsValues; + +const defaultCustomOptions = { + type: customPropertyTypes.stringAndCounter, + prefix: "", + suffix: "", + counterStart: 1, + counterScale: 1, + stringValue: "", +} satisfies CustomPropertyOptionsValues; + +// [code key, UI label, backend FixedValueType, guaranteesUniqueness] +const sharedFixedPropertySpecs = [ + ["fixedMacAddress", "MAC address", FixedValueType.MAC_ADDRESS, true], + ["fixedSerialNumber", "Serial number", FixedValueType.SERIAL_NUMBER, true], + ["fixedModel", "Model", FixedValueType.MODEL, false], + ["fixedManufacturer", "Manufacturer", FixedValueType.MANUFACTURER, false], +] as const; + +const renameOnlyFixedPropertySpecs = [["fixedWorkerName", "Worker name", FixedValueType.WORKER_NAME, false]] as const; + +const workerOnlyFixedPropertySpecs = [["fixedMinerName", "Miner name", FixedValueType.MINER_NAME, false]] as const; + +const fixedPropertySpecs = [ + ...sharedFixedPropertySpecs, + ...renameOnlyFixedPropertySpecs, + ...workerOnlyFixedPropertySpecs, +] as const satisfies readonly FixedPropertySpec[]; + +// [code key, UI label, backend QualifierType] +const workerQualifierPropertySpecs = [ + ["qualifierRack", "Rack", QualifierType.RACK], + ["qualifierRackPosition", "Rack position", QualifierType.RACK_POSITION], +] as const satisfies readonly QualifierPropertySpec[]; + +const customPropertySpec = ["custom", "Custom"] as const; + +const propertySpecs = [...fixedPropertySpecs, ...workerQualifierPropertySpecs, customPropertySpec] as const; + +type BulkRenamePropertyKey = (typeof propertySpecs)[number][0]; + +export const bulkRenamePropertyIds = Object.fromEntries( + propertySpecs.map(([key]) => [key, key.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`)]), +) as { + [Spec in (typeof propertySpecs)[number] as Spec[0]]: KebabCase; +}; + +export type BulkRenamePropertyId = (typeof bulkRenamePropertyIds)[keyof typeof bulkRenamePropertyIds]; + +const createQualifierPropertyDefinition = ( + key: string, + label: string, + qualifierType: QualifierType, +): BulkRenamePropertyDefinition => ({ + id: bulkRenamePropertyIds[key as keyof typeof bulkRenamePropertyIds], + label, + kind: "qualifier", + qualifierType, + defaultOptions: { prefix: "", suffix: "" } satisfies QualifierOptionsValues, +}); + +const BULK_RENAME_PROPERTY_DEFINITIONS: BulkRenamePropertyDefinition[] = [ + ...fixedPropertySpecs.map(([key, label, fixedValueType, guaranteesUniqueness]) => ({ + id: bulkRenamePropertyIds[key], + label, + kind: "fixed" as const, + fixedValueType, + guaranteesUniqueness, + defaultOptions: defaultFixedValueOptions, + })), + ...workerQualifierPropertySpecs.map((spec) => createQualifierPropertyDefinition(spec[0], spec[1], spec[2])), + { + id: bulkRenamePropertyIds[customPropertySpec[0]], + label: customPropertySpec[1], + kind: "custom", + defaultOptions: defaultCustomOptions, + }, +]; + +const BULK_RENAME_MODE_PROPERTY_KEYS: Record = { + [bulkRenameModes.rename]: [ + "fixedMacAddress", + "fixedSerialNumber", + "fixedWorkerName", + "fixedModel", + "fixedManufacturer", + "qualifierRack", + "qualifierRackPosition", + "custom", + ], + [bulkRenameModes.worker]: [ + "fixedMacAddress", + "fixedSerialNumber", + "fixedMinerName", + "fixedModel", + "fixedManufacturer", + "qualifierRack", + "qualifierRackPosition", + "custom", + ], +}; + +const getBulkRenameModeDefinitions = (mode: BulkRenameMode): BulkRenamePropertyDefinition[] => + BULK_RENAME_MODE_PROPERTY_KEYS[mode].map((key) => { + const definition = propertyDefinitionsById.get(bulkRenamePropertyIds[key]); + + if (definition === undefined) { + throw new Error(`Unknown bulk rename property key: ${key}`); + } + + return definition; + }); + +const propertyDefinitionsById = new Map( + BULK_RENAME_PROPERTY_DEFINITIONS.map((definition) => [definition.id, definition]), +); + +const cloneOptions = (options: BulkRenamePropertyOptions): BulkRenamePropertyOptions => { + return JSON.parse(JSON.stringify(options)) as BulkRenamePropertyOptions; +}; + +const mergeBulkRenamePropertyOptions = ( + definition: BulkRenamePropertyDefinition, + options?: BulkRenamePropertyOptions, +): BulkRenamePropertyOptions => ({ + ...cloneOptions(definition.defaultOptions), + ...(typeof options === "object" && options !== null ? options : {}), +}); + +const createBulkRenamePropertyState = ( + definition: BulkRenamePropertyDefinition, + persistedState?: Partial, +): BulkRenamePropertyState => ({ + id: definition.id, + enabled: persistedState?.enabled ?? false, + options: mergeBulkRenamePropertyOptions(definition, persistedState?.options), +}); + +export const getBulkRenamePropertyDefinition = (id: BulkRenamePropertyId): BulkRenamePropertyDefinition => { + const definition = propertyDefinitionsById.get(id); + + if (definition === undefined) { + throw new Error(`Unknown bulk rename property id: ${id}`); + } + + return definition; +}; + +export const createDefaultBulkRenamePreferences = ( + mode: BulkRenameMode = bulkRenameModes.rename, +): BulkRenamePreferences => ({ + separator: bulkRenameSeparatorIds.dash, + properties: getBulkRenameModeDefinitions(mode).map((definition) => createBulkRenamePropertyState(definition)), +}); + +export const normalizeBulkRenamePreferences = ( + preferences?: Partial | null, + mode: BulkRenameMode = bulkRenameModes.rename, +): BulkRenamePreferences => { + const defaults = createDefaultBulkRenamePreferences(mode); + const separator = + preferences?.separator !== undefined && preferences.separator in bulkRenameSeparators + ? (preferences.separator as BulkRenameSeparatorId) + : defaults.separator; + + const availableDefinitions = new Map( + defaults.properties.map((property) => [property.id, getBulkRenamePropertyDefinition(property.id)]), + ); + const persistedStates = preferences?.properties ?? []; + const seen = new Set(); + const properties: BulkRenamePropertyState[] = []; + + for (const state of persistedStates) { + const definition = availableDefinitions.get(state.id); + if (definition === undefined || seen.has(state.id)) { + continue; + } + + seen.add(state.id); + properties.push(createBulkRenamePropertyState(definition, state)); + } + + for (const state of defaults.properties) { + if (seen.has(state.id)) { + continue; + } + + properties.push(state); + } + + return { + separator, + properties, + }; +}; + +export const updateBulkRenameProperty = ( + preferences: BulkRenamePreferences, + propertyId: BulkRenamePropertyId, + updater: (property: BulkRenamePropertyState) => BulkRenamePropertyState, +): BulkRenamePreferences => ({ + ...preferences, + properties: preferences.properties.map((property) => (property.id === propertyId ? updater(property) : property)), +}); + +export const reorderBulkRenameProperties = ( + preferences: BulkRenamePreferences, + activeId: BulkRenamePropertyId, + overId: BulkRenamePropertyId, +): BulkRenamePreferences => { + const oldIndex = preferences.properties.findIndex((property) => property.id === activeId); + const newIndex = preferences.properties.findIndex((property) => property.id === overId); + + if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) { + return preferences; + } + + const properties = [...preferences.properties]; + const [movedProperty] = properties.splice(oldIndex, 1); + properties.splice(newIndex, 0, movedProperty); + + return { + ...preferences, + properties, + }; +}; + +export const getEnabledBulkRenameProperties = (preferences: BulkRenamePreferences): BulkRenamePropertyState[] => + preferences.properties.filter((property) => property.enabled); + +export const isBulkRenamePropertyUniquenessGuaranteeing = ( + property: BulkRenamePropertyState, + previewMiners: BulkRenamePreviewMiner[] | null = null, +): boolean => { + const definition = getBulkRenamePropertyDefinition(property.id); + + if (definition.guaranteesUniqueness) { + const options = property.options as FixedValueOptionsValues; + return ( + options.characterCount === fixedCharacterCountAll && + previewMiners !== null && + previewMiners.every((miner) => hasNonEmptyUniquenessValue(property, miner)) + ); + } + + if (definition.kind !== "custom") { + return false; + } + + const options = property.options as CustomPropertyOptionsValues; + return ( + options.counterStart !== undefined && + (options.type === customPropertyTypes.counterOnly || options.type === customPropertyTypes.stringAndCounter) + ); +}; + +export const hasUniquenessGuaranteeingProperty = ( + preferences: BulkRenamePreferences, + previewMiners: BulkRenamePreviewMiner[] | null = null, +): boolean => + getEnabledBulkRenameProperties(preferences).some((property) => + isBulkRenamePropertyUniquenessGuaranteeing(property, previewMiners), + ); + +export const shouldWarnAboutBulkRenameDuplicates = ( + selectionCount: number, + preferences: BulkRenamePreferences, + previewMiners: BulkRenamePreviewMiner[] | null = null, +): boolean => selectionCount > 1 && !hasUniquenessGuaranteeingProperty(preferences, previewMiners); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/bulkRenamePreview.test.ts b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/bulkRenamePreview.test.ts new file mode 100644 index 000000000..b3bd2c3cf --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/bulkRenamePreview.test.ts @@ -0,0 +1,461 @@ +import { describe, expect, it } from "vitest"; +import { + bulkRenameModes, + type BulkRenamePreviewMiner, + bulkRenamePropertyIds, + bulkRenameSeparatorIds, + createDefaultBulkRenamePreferences, +} from "./bulkRenameDefinitions"; +import { + buildBulkRenameConfig, + evaluateBulkRenamePreviewName, + findBulkRenamePropertyPreviewMinerIndex, + hasEmptyBulkRenameConfig, + hasNoBulkRenameChanges, + mapSnapshotsToBulkRenamePreviewMiners, + shouldShowBulkRenameNoChangesWarning, + takePreviewMiners, +} from "./bulkRenamePreview"; +import { customPropertyTypes, fixedStringSections } from "./RenameOptionsModals/types"; + +const basePreviewMiner: BulkRenamePreviewMiner = { + counterIndex: 0, + deviceIdentifier: "device-1", + currentName: "Proto Rig", + storedName: "Proto Rig", + macAddress: "AA:BB:CC:DD:EE:FF", + serialNumber: "SER123456", + minerName: "Proto Rig", + model: "S21 XP", + manufacturer: "Bitmain", + workerName: "worker-01", + rackLabel: "Rack-A1", + rackPosition: "12", +}; + +describe("bulkRenamePreview", () => { + it("builds a config from enabled properties in persisted order", () => { + const preferences = createDefaultBulkRenamePreferences(); + preferences.separator = bulkRenameSeparatorIds.period; + + preferences.properties = preferences.properties.map((property) => { + if (property.id === bulkRenamePropertyIds.fixedManufacturer) { + return { ...property, enabled: true }; + } + + if (property.id === bulkRenamePropertyIds.custom) { + return { + ...property, + enabled: true, + options: { + ...property.options, + type: customPropertyTypes.counterOnly, + counterStart: 7, + counterScale: 3, + }, + }; + } + + return property; + }); + + const config = buildBulkRenameConfig(preferences); + + expect(config.separator).toBe("."); + expect(config.properties).toHaveLength(2); + expect(config.properties[0].kind.case).toBe("fixedValue"); + expect(config.properties[1].kind.case).toBe("counter"); + }); + + it("evaluates preview names with fixed values and counters", () => { + const preferences = createDefaultBulkRenamePreferences(); + preferences.properties = preferences.properties.map((property) => { + if (property.id === bulkRenamePropertyIds.fixedManufacturer) { + return { ...property, enabled: true }; + } + + if (property.id === bulkRenamePropertyIds.custom) { + return { + ...property, + enabled: true, + options: { + ...property.options, + type: customPropertyTypes.stringAndCounter, + prefix: "M", + suffix: "", + counterStart: 1, + counterScale: 2, + stringValue: "", + }, + }; + } + + return property; + }); + + const config = buildBulkRenameConfig(preferences); + expect(evaluateBulkRenamePreviewName(config, basePreviewMiner, 0)).toBe("Bitmain-M01"); + expect(evaluateBulkRenamePreviewName(config, basePreviewMiner, 1)).toBe("Bitmain-M02"); + }); + + it("evaluates worker-name previews with miner name and rack qualifiers", () => { + const preferences = createDefaultBulkRenamePreferences(bulkRenameModes.worker); + preferences.properties = preferences.properties.map((property) => { + if (property.id === bulkRenamePropertyIds.fixedMinerName) { + return { ...property, enabled: true }; + } + + if (property.id === bulkRenamePropertyIds.qualifierRack) { + return { + ...property, + enabled: true, + options: { + prefix: "", + suffix: "", + }, + }; + } + + if (property.id === bulkRenamePropertyIds.qualifierRackPosition) { + return { + ...property, + enabled: true, + options: { + prefix: "", + suffix: "", + }, + }; + } + + return property; + }); + + const config = buildBulkRenameConfig(preferences); + + expect(evaluateBulkRenamePreviewName(config, basePreviewMiner, 0)).toBe("Proto Rig-Rack-A1-12"); + }); + + it("treats empty or unchanged bulk rename results as no-op changes", () => { + const defaults = createDefaultBulkRenamePreferences(); + + expect(hasNoBulkRenameChanges(defaults, [basePreviewMiner])).toBe(true); + + const unchangedPreferences = createDefaultBulkRenamePreferences(); + unchangedPreferences.properties = unchangedPreferences.properties.map((property) => { + if (property.id === bulkRenamePropertyIds.fixedMacAddress) { + return { + ...property, + enabled: true, + options: { + characterCount: "all", + stringSection: fixedStringSections.last, + }, + }; + } + + return property; + }); + + expect( + hasNoBulkRenameChanges(unchangedPreferences, [ + { + ...basePreviewMiner, + currentName: "AA:BB:CC:DD:EE:FF", + storedName: "AA:BB:CC:DD:EE:FF", + }, + ]), + ).toBe(true); + }); + + it("compares no-change checks against stored miner names, not display-name fallbacks", () => { + const preferences = createDefaultBulkRenamePreferences(); + preferences.properties = preferences.properties.map((property) => { + if (property.id === bulkRenamePropertyIds.custom) { + return { + ...property, + enabled: true, + options: { + ...property.options, + type: customPropertyTypes.stringOnly, + stringValue: "Bitmain S21 XP", + }, + }; + } + + return property; + }); + + expect( + hasNoBulkRenameChanges(preferences, [ + { + ...basePreviewMiner, + currentName: "Bitmain S21 XP", + storedName: "", + }, + ]), + ).toBe(false); + }); + + it("uses each preview miner's real counter index for no-op detection", () => { + const preferences = createDefaultBulkRenamePreferences(); + preferences.properties = preferences.properties.map((property) => { + if (property.id === bulkRenamePropertyIds.custom) { + return { + ...property, + enabled: true, + options: { + ...property.options, + type: customPropertyTypes.counterOnly, + counterStart: 1, + counterScale: 3, + }, + }; + } + + return property; + }); + + expect( + hasNoBulkRenameChanges(preferences, [ + { + ...basePreviewMiner, + counterIndex: 69, + currentName: "070", + storedName: "070", + }, + ]), + ).toBe(true); + }); + + it("preserves the provided table order when assigning preview counter indices", () => { + const previewMiners = mapSnapshotsToBulkRenamePreviewMiners([ + { + deviceIdentifier: "device-2", + name: "Alpha", + manufacturer: "Bitmain", + model: "S21", + macAddress: "AA:AA:AA:AA:AA:02", + serialNumber: "SER-2", + workerName: "worker-02", + rackLabel: "", + rackPosition: "", + }, + { + deviceIdentifier: "device-3", + name: "Zulu", + manufacturer: "Avalon", + model: "A1", + macAddress: "AA:AA:AA:AA:AA:03", + serialNumber: "SER-3", + workerName: "worker-03", + rackLabel: "", + rackPosition: "", + }, + { + deviceIdentifier: "device-1", + name: "Beta", + manufacturer: "Bitmain", + model: "S19", + macAddress: "AA:AA:AA:AA:AA:01", + serialNumber: "SER-1", + workerName: "worker-01", + rackLabel: "", + rackPosition: "", + }, + ]); + + expect(previewMiners.map((miner) => [miner.deviceIdentifier, miner.counterIndex])).toEqual([ + ["device-2", 0], + ["device-3", 1], + ["device-1", 2], + ]); + }); + + it("does not reorder rows when manufacturer or model values are blank", () => { + const previewMiners = mapSnapshotsToBulkRenamePreviewMiners([ + { + deviceIdentifier: "device-1", + name: "One", + manufacturer: "A", + model: "", + macAddress: "AA:AA:AA:AA:AA:01", + serialNumber: "SER-1", + workerName: "worker-01", + rackLabel: "", + rackPosition: "", + }, + { + deviceIdentifier: "device-2", + name: "Two", + manufacturer: "", + model: "A", + macAddress: "AA:AA:AA:AA:AA:02", + serialNumber: "SER-2", + workerName: "worker-02", + rackLabel: "", + rackPosition: "", + }, + ]); + + expect(previewMiners.map((miner) => miner.deviceIdentifier)).toEqual(["device-1", "device-2"]); + }); + + it("does not duplicate rows when preview miners are already a partial sample", () => { + const previewMiners = [ + { deviceIdentifier: "device-1" }, + { deviceIdentifier: "device-2" }, + { deviceIdentifier: "device-3" }, + { deviceIdentifier: "device-4" }, + ]; + + expect(takePreviewMiners(previewMiners, 10)).toEqual({ + miners: previewMiners, + showEllipsis: true, + }); + }); + + it("limits compact previews to a single row without showing a desktop ellipsis marker", () => { + const previewMiners = [{ deviceIdentifier: "device-1" }, { deviceIdentifier: "device-2" }]; + + expect(takePreviewMiners(previewMiners, 2, 1)).toEqual({ + miners: [previewMiners[0]], + showEllipsis: false, + }); + }); + + it("does not treat an empty preview set as unchanged when a real name config exists", () => { + const preferences = createDefaultBulkRenamePreferences(); + preferences.properties = preferences.properties.map((property) => { + if (property.id === bulkRenamePropertyIds.fixedMacAddress) { + return { + ...property, + enabled: true, + options: { + characterCount: "all", + stringSection: fixedStringSections.last, + }, + }; + } + + return property; + }); + + expect(hasNoBulkRenameChanges(preferences, [])).toBe(false); + }); + + it("treats an empty rename config as a no-change warning even without validation miners", () => { + const preferences = createDefaultBulkRenamePreferences(); + + expect(hasEmptyBulkRenameConfig(preferences)).toBe(true); + expect(shouldShowBulkRenameNoChangesWarning(preferences, null)).toBe(true); + }); + + it("does not show a no-change warning without validation miners when the config has real properties", () => { + const preferences = createDefaultBulkRenamePreferences(); + preferences.properties = preferences.properties.map((property) => { + if (property.id === bulkRenamePropertyIds.fixedMacAddress) { + return { + ...property, + enabled: true, + options: { + characterCount: "all", + stringSection: fixedStringSections.last, + }, + }; + } + + return property; + }); + + expect(hasEmptyBulkRenameConfig(preferences)).toBe(false); + expect(shouldShowBulkRenameNoChangesWarning(preferences, null)).toBe(false); + }); + + it("prefers a preview miner that has a value for non-custom property previews", () => { + const preferences = createDefaultBulkRenamePreferences(); + preferences.properties = preferences.properties.map((property) => { + if (property.id === bulkRenamePropertyIds.fixedSerialNumber) { + return { + ...property, + enabled: true, + options: { + characterCount: "all", + stringSection: fixedStringSections.last, + }, + }; + } + + return property; + }); + + expect( + findBulkRenamePropertyPreviewMinerIndex(preferences, bulkRenamePropertyIds.fixedSerialNumber, [ + { + ...basePreviewMiner, + deviceIdentifier: "device-1", + serialNumber: "", + }, + { + ...basePreviewMiner, + deviceIdentifier: "device-2", + serialNumber: "SER987654", + }, + ]), + ).toBe(1); + }); + + it("keeps custom property previews on the first preview miner", () => { + const preferences = createDefaultBulkRenamePreferences(); + preferences.properties = preferences.properties.map((property) => { + if (property.id === bulkRenamePropertyIds.custom) { + return { + ...property, + enabled: true, + options: { + ...property.options, + type: customPropertyTypes.stringOnly, + stringValue: "Fleet", + }, + }; + } + + return property; + }); + + expect( + findBulkRenamePropertyPreviewMinerIndex(preferences, bulkRenamePropertyIds.custom, [ + { + ...basePreviewMiner, + deviceIdentifier: "device-1", + }, + { + ...basePreviewMiner, + deviceIdentifier: "device-2", + }, + ]), + ).toBe(0); + }); + + it("maps worker-mode previews from stored worker names instead of fleet display names", () => { + const [previewMiner] = mapSnapshotsToBulkRenamePreviewMiners( + [ + { + deviceIdentifier: "device-1", + name: "", + manufacturer: "Bitmain", + model: "S21 XP", + macAddress: "AA:BB:CC:DD:EE:FF", + serialNumber: "SER123456", + workerName: "worker-99", + rackLabel: "Rack-A1", + rackPosition: "12", + }, + ], + bulkRenameModes.worker, + ); + + expect(previewMiner.currentName).toBe("worker-99"); + expect(previewMiner.storedName).toBe("worker-99"); + expect(previewMiner.minerName).toBe("Bitmain S21 XP"); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/bulkRenamePreview.ts b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/bulkRenamePreview.ts new file mode 100644 index 000000000..b8071f46e --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/bulkRenamePreview.ts @@ -0,0 +1,426 @@ +import { create } from "@bufbuild/protobuf"; +import { + bulkRenameModes, + bulkRenameSeparators, + getBulkRenamePropertyDefinition, + getEnabledBulkRenameProperties, +} from "./bulkRenameDefinitions"; +import type { + BulkRenameMode, + BulkRenamePreferences, + BulkRenamePreviewMiner, + BulkRenamePropertyId, + BulkRenamePropertyPreview, + BulkRenamePropertyState, +} from "./bulkRenameDefinitions"; +import { + type CustomPropertyOptionsValues, + customPropertyTypes, + fixedStringSections, + type FixedValueOptionsValues, + type QualifierOptionsValues, +} from "./RenameOptionsModals/types"; +import { + CharacterSection, + CounterPropertySchema, + FixedValuePropertySchema, + FixedValueType, + type MinerNameConfig, + MinerNameConfigSchema, + type NameProperty, + NamePropertySchema, + QualifierPropertySchema, + QualifierType, + StringAndCounterPropertySchema, + StringPropertySchema, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +function formatCounter(value: number, scale: number): string { + return value.toString().padStart(scale, "0"); +} + +function getMinerDisplayName(snapshot: Pick): string { + if (snapshot.name.trim() !== "") { + return snapshot.name; + } + + return `${snapshot.manufacturer} ${snapshot.model}`.trim(); +} + +function getFixedValueSection(options: FixedValueOptionsValues): CharacterSection | undefined { + if (options.characterCount === "all") { + return undefined; + } + + return options.stringSection === fixedStringSections.last ? CharacterSection.LAST : CharacterSection.FIRST; +} + +function getFixedPreviewValue(type: FixedValueType, miner: BulkRenamePreviewMiner): string { + switch (type) { + case FixedValueType.MAC_ADDRESS: + return miner.macAddress; + case FixedValueType.SERIAL_NUMBER: + return miner.serialNumber; + case FixedValueType.WORKER_NAME: + return miner.workerName; + case FixedValueType.MINER_NAME: + return miner.minerName; + case FixedValueType.MODEL: + return miner.model; + case FixedValueType.MANUFACTURER: + return miner.manufacturer; + case FixedValueType.LOCATION: + case FixedValueType.UNSPECIFIED: + return ""; + } +} + +function getQualifierPreviewValue(type: QualifierType, miner: BulkRenamePreviewMiner): string { + switch (type) { + case QualifierType.RACK: + return miner.rackLabel; + case QualifierType.RACK_POSITION: + return miner.rackPosition; + case QualifierType.BUILDING: + case QualifierType.UNSPECIFIED: + return ""; + } +} + +function truncateFixedPreviewValue(value: string, characterCount: number, section: CharacterSection): string { + const runes = Array.from(value); + + if (characterCount >= runes.length) { + return value; + } + + if (section === CharacterSection.LAST) { + return runes.slice(-characterCount).join(""); + } + + return runes.slice(0, characterCount).join(""); +} + +function buildNameProperty(property: BulkRenamePropertyState): NameProperty | null { + const definition = getBulkRenamePropertyDefinition(property.id); + + if (definition.kind === "custom") { + const options = property.options as CustomPropertyOptionsValues; + + if (options.type === customPropertyTypes.stringOnly) { + const stringValue = options.stringValue.trim(); + if (stringValue === "") { + return null; + } + + return create(NamePropertySchema, { + kind: { + case: "stringValue", + value: create(StringPropertySchema, { value: stringValue }), + }, + }); + } + + if (options.counterStart === undefined) { + return null; + } + + if (options.type === customPropertyTypes.counterOnly) { + return create(NamePropertySchema, { + kind: { + case: "counter", + value: create(CounterPropertySchema, { + counterStart: options.counterStart, + counterScale: options.counterScale, + }), + }, + }); + } + + return create(NamePropertySchema, { + kind: { + case: "stringAndCounter", + value: create(StringAndCounterPropertySchema, { + prefix: options.prefix.trim(), + suffix: options.suffix.trim(), + counterStart: options.counterStart, + counterScale: options.counterScale, + }), + }, + }); + } + + if (definition.kind === "fixed") { + const options = property.options as FixedValueOptionsValues; + + return create(NamePropertySchema, { + kind: { + case: "fixedValue", + value: create(FixedValuePropertySchema, { + type: definition.fixedValueType, + characterCount: options.characterCount === "all" ? undefined : options.characterCount, + section: getFixedValueSection(options), + }), + }, + }); + } + + const options = property.options as QualifierOptionsValues; + + return create(NamePropertySchema, { + kind: { + case: "qualifier", + value: create(QualifierPropertySchema, { + type: definition.qualifierType, + prefix: options.prefix.trim(), + suffix: options.suffix.trim(), + }), + }, + }); +} + +function evaluateNameProperty(property: NameProperty, miner: BulkRenamePreviewMiner, counterIndex: number): string { + switch (property.kind.case) { + case "stringAndCounter": + return `${property.kind.value.prefix}${formatCounter( + property.kind.value.counterStart + counterIndex, + property.kind.value.counterScale, + )}${property.kind.value.suffix}`; + case "counter": + return formatCounter(property.kind.value.counterStart + counterIndex, property.kind.value.counterScale); + case "stringValue": + return property.kind.value.value; + case "fixedValue": { + const rawValue = getFixedPreviewValue(property.kind.value.type, miner); + + if (rawValue === "") { + return ""; + } + + if (property.kind.value.characterCount === undefined) { + return rawValue; + } + + const characterCount = property.kind.value.characterCount; + const section = + property.kind.value.section === CharacterSection.LAST ? CharacterSection.LAST : CharacterSection.FIRST; + + return truncateFixedPreviewValue(rawValue, characterCount, section); + } + case "qualifier": { + const rawValue = getQualifierPreviewValue(property.kind.value.type, miner); + + if (rawValue.trim() === "") { + return ""; + } + + return `${property.kind.value.prefix}${rawValue}${property.kind.value.suffix}`; + } + case undefined: + return ""; + } +} + +function evaluateBulkRenamePropertySegment( + property: BulkRenamePropertyState, + miner: BulkRenamePreviewMiner, + counterIndex: number, +): string { + const nameProperty = buildNameProperty(property); + + return nameProperty === null ? "" : evaluateNameProperty(nameProperty, miner, counterIndex); +} + +export const buildBulkRenameConfig = (preferences: BulkRenamePreferences): MinerNameConfig => + create(MinerNameConfigSchema, { + separator: bulkRenameSeparators[preferences.separator].value, + properties: getEnabledBulkRenameProperties(preferences) + .map(buildNameProperty) + .filter((property): property is NameProperty => property !== null), + }); + +export const evaluateBulkRenamePreviewName = ( + config: MinerNameConfig, + miner: BulkRenamePreviewMiner, + counterIndex: number, +): string => { + const segments = config.properties + .map((property) => evaluateNameProperty(property, miner, counterIndex)) + .filter((segment) => segment.trim() !== ""); + + return segments.join(config.separator).trim(); +}; + +export const hasEmptyBulkRenameConfig = (preferences: BulkRenamePreferences): boolean => + buildBulkRenameConfig(preferences).properties.length === 0; + +export const hasNoBulkRenameChanges = ( + preferences: BulkRenamePreferences, + previewMiners: BulkRenamePreviewMiner[], +): boolean => { + if (getEnabledBulkRenameProperties(preferences).length === 0 || hasEmptyBulkRenameConfig(preferences)) { + return true; + } + + if (previewMiners.length === 0) { + return false; + } + + const config = buildBulkRenameConfig(preferences); + const previewNames = previewMiners.map((miner) => evaluateBulkRenamePreviewName(config, miner, miner.counterIndex)); + + if (previewNames.every((name) => name.trim() === "")) { + return true; + } + + return previewNames.every((name, index) => name.trim() === previewMiners[index]?.storedName.trim()); +}; + +export const shouldShowBulkRenameNoChangesWarning = ( + preferences: BulkRenamePreferences, + previewMiners: BulkRenamePreviewMiner[] | null, +): boolean => + hasEmptyBulkRenameConfig(preferences) || + (previewMiners !== null && hasNoBulkRenameChanges(preferences, previewMiners)); + +export const getMinerPreviewName = ( + snapshot: Pick, +): string => getMinerDisplayName(snapshot); + +type BulkRenamePreviewSnapshot = Pick< + MinerStateSnapshot, + | "deviceIdentifier" + | "name" + | "manufacturer" + | "model" + | "macAddress" + | "serialNumber" + | "workerName" + | "rackLabel" + | "rackPosition" +>; + +export const mapSnapshotToBulkRenamePreviewMiner = ( + snapshot: BulkRenamePreviewSnapshot, + counterIndex: number, + mode: BulkRenameMode = bulkRenameModes.rename, +): BulkRenamePreviewMiner => ({ + counterIndex, + deviceIdentifier: snapshot.deviceIdentifier, + currentName: mode === bulkRenameModes.worker ? snapshot.workerName : getMinerDisplayName(snapshot), + storedName: mode === bulkRenameModes.worker ? snapshot.workerName : snapshot.name, + macAddress: snapshot.macAddress, + serialNumber: snapshot.serialNumber, + minerName: getMinerDisplayName(snapshot), + model: snapshot.model, + manufacturer: snapshot.manufacturer, + workerName: snapshot.workerName, + rackLabel: snapshot.rackLabel, + rackPosition: snapshot.rackPosition, +}); + +export const mapSnapshotsToBulkRenamePreviewMiners = ( + snapshots: BulkRenamePreviewSnapshot[], + mode: BulkRenameMode = bulkRenameModes.rename, +): BulkRenamePreviewMiner[] => + snapshots.map((snapshot, counterIndex) => mapSnapshotToBulkRenamePreviewMiner(snapshot, counterIndex, mode)); + +export const takePreviewMiners = ( + miners: T[], + totalCount: number, + maxVisibleMiners: number = 6, +): { miners: T[]; showEllipsis: boolean } => { + if (maxVisibleMiners <= 0 || totalCount <= 0 || miners.length === 0) { + return { + miners: [], + showEllipsis: false, + }; + } + + if (maxVisibleMiners === 1) { + return { + miners: miners.slice(0, 1), + showEllipsis: false, + }; + } + + if (totalCount <= maxVisibleMiners || miners.length <= maxVisibleMiners) { + return { + miners, + showEllipsis: totalCount > miners.length, + }; + } + + const headCount = Math.floor(maxVisibleMiners / 2); + const tailCount = maxVisibleMiners - headCount; + + return { + miners: [...miners.slice(0, headCount), ...miners.slice(-tailCount)], + showEllipsis: true, + }; +}; + +export const buildBulkRenamePropertyPreview = ( + preferences: BulkRenamePreferences, + propertyId: BulkRenamePropertyId, + miner: BulkRenamePreviewMiner, + counterIndex: number, +): BulkRenamePropertyPreview => { + const separator = bulkRenameSeparators[preferences.separator].value; + const segments = getEnabledBulkRenameProperties(preferences) + .map((property) => ({ + propertyId: property.id, + value: evaluateBulkRenamePropertySegment(property, miner, counterIndex), + })) + .filter((segment) => segment.value.trim() !== ""); + + let previewName = ""; + let highlightStartIndex: number | undefined; + let highlightedText: string | undefined; + + for (const segment of segments) { + if (previewName !== "") { + previewName += separator; + } + + const valueStartIndex = previewName.length; + previewName += segment.value; + + if (segment.propertyId === propertyId) { + highlightedText = segment.value; + highlightStartIndex = valueStartIndex; + } + } + + return { + previewName: previewName.trim(), + highlightedText, + highlightStartIndex, + }; +}; + +export const findBulkRenamePropertyPreviewMinerIndex = ( + preferences: BulkRenamePreferences, + propertyId: BulkRenamePropertyId, + previewMiners: BulkRenamePreviewMiner[], +): number | null => { + if (previewMiners.length === 0) { + return null; + } + + const property = preferences.properties.find((candidate) => candidate.id === propertyId); + if (property === undefined) { + return 0; + } + + if (getBulkRenamePropertyDefinition(propertyId).kind === "custom") { + return 0; + } + + const previewMinerIndex = previewMiners.findIndex( + (miner) => evaluateBulkRenamePropertySegment(property, miner, miner.counterIndex).trim() !== "", + ); + + return previewMinerIndex === -1 ? null : previewMinerIndex; +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/bulkRenameToastMessages.test.ts b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/bulkRenameToastMessages.test.ts new file mode 100644 index 000000000..705e81bb7 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/bulkRenameToastMessages.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { + getBulkRenameFailureMessage, + getBulkRenameLoadingMessage, + getBulkRenameRequestFailureMessage, + getBulkRenameSuccessMessage, +} from "./bulkRenameToastMessages"; + +describe("bulkRenameToastMessages", () => { + it("builds loading messages for single and bulk renames", () => { + expect(getBulkRenameLoadingMessage(1)).toBe("Renaming miner"); + expect(getBulkRenameLoadingMessage(3)).toBe("Renaming miners"); + }); + + it("builds success messages for renamed-only, unchanged-only, and mixed outcomes", () => { + expect(getBulkRenameSuccessMessage(2, 0)).toBe("Renamed 2 miners"); + expect(getBulkRenameSuccessMessage(0, 1)).toBe("1 miner unchanged"); + expect(getBulkRenameSuccessMessage(4, 2)).toBe("Renamed 4 miners; 2 miners unchanged"); + }); + + it("builds failure messages for partial and full failures", () => { + expect(getBulkRenameFailureMessage(1)).toBe("Failed to rename 1 miner"); + expect(getBulkRenameFailureMessage(5)).toBe("Failed to rename 5 miners"); + }); + + it("builds request failure messages for single and bulk renames", () => { + expect(getBulkRenameRequestFailureMessage(1)).toBe("Failed to rename miner"); + expect(getBulkRenameRequestFailureMessage(2)).toBe("Failed to rename miners"); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/bulkRenameToastMessages.ts b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/bulkRenameToastMessages.ts new file mode 100644 index 000000000..1c64a6062 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/bulkRenameToastMessages.ts @@ -0,0 +1,22 @@ +const formatMinerCount = (count: number): string => `${count} miner${count === 1 ? "" : "s"}`; + +export const getBulkRenameLoadingMessage = (selectionCount: number): string => + selectionCount === 1 ? "Renaming miner" : "Renaming miners"; + +export const getBulkRenameSuccessMessage = (renamedCount: number, unchangedCount: number): string => { + if (unchangedCount === 0) { + return `Renamed ${formatMinerCount(renamedCount)}`; + } + + if (renamedCount === 0) { + return `${formatMinerCount(unchangedCount)} unchanged`; + } + + return `Renamed ${formatMinerCount(renamedCount)}; ${formatMinerCount(unchangedCount)} unchanged`; +}; + +export const getBulkRenameFailureMessage = (failedCount: number): string => + `Failed to rename ${formatMinerCount(failedCount)}`; + +export const getBulkRenameRequestFailureMessage = (selectionCount: number): string => + selectionCount === 1 ? "Failed to rename miner" : "Failed to rename miners"; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/constants.ts b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/constants.ts new file mode 100644 index 000000000..17be21c9b --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/constants.ts @@ -0,0 +1,141 @@ +// Device Actions +export const deviceActions = { + blinkLEDs: "blink-leds", + downloadLogs: "download-logs", + firmwareUpdate: "firmware-update", + factoryReset: "factory-reset", + reboot: "reboot", + shutdown: "shutdown", + unpair: "unpair", + wakeUp: "wake-up", +} as const; + +export type DeviceAction = (typeof deviceActions)[keyof typeof deviceActions]; + +// Performance Actions +export const performanceActions = { + managePower: "manage-power", + curtail: "curtail", +} as const; + +export type PerformanceAction = (typeof performanceActions)[keyof typeof performanceActions]; + +// Settings Actions +export const settingsActions = { + miningPool: "mining-pool", + coolingMode: "cooling-mode", + rename: "rename", + updateWorkerNames: "update-worker-names", + security: "security", +} as const; + +export type SettingsAction = (typeof settingsActions)[keyof typeof settingsActions]; + +// Group Actions +export const groupActions = { + addToGroup: "add-to-group", +} as const; + +export type GroupAction = (typeof groupActions)[keyof typeof groupActions]; + +// All Actions Combined +export const allActions = { + ...deviceActions, + ...performanceActions, + ...settingsActions, + ...groupActions, +} as const; + +export type SupportedAction = (typeof allActions)[keyof typeof allActions]; + +export const minersMessage = "miners"; + +export const loadingMessages: Record = { + [deviceActions.blinkLEDs]: "Blinking LEDs", + [deviceActions.downloadLogs]: "Downloading logs", + [deviceActions.factoryReset]: "Resetting", + [deviceActions.reboot]: "Rebooting", + [deviceActions.shutdown]: "Putting to sleep", + [deviceActions.unpair]: "Unpairing", + [deviceActions.wakeUp]: "Waking up", + [deviceActions.firmwareUpdate]: "Updating firmware on", + [performanceActions.managePower]: "Updating power settings for", + [performanceActions.curtail]: "Curtailing miners", + [settingsActions.miningPool]: "Assigning pools", + [settingsActions.coolingMode]: "Setting cooling mode for", + [settingsActions.rename]: "Renaming miner", + [settingsActions.updateWorkerNames]: "Updating worker names for", + [settingsActions.security]: "Updating security for", + [groupActions.addToGroup]: "Adding to group", +}; + +export const statusColumnLoadingMessages: Record = { + [deviceActions.blinkLEDs]: "Blinking LEDs", + [deviceActions.factoryReset]: "Resetting", + [deviceActions.reboot]: "Rebooting", + [deviceActions.shutdown]: "Sleeping", + [deviceActions.unpair]: "Unpairing", + [deviceActions.wakeUp]: "Waking", + [deviceActions.firmwareUpdate]: "Updating firmware", + [performanceActions.managePower]: "Updating power", + [performanceActions.curtail]: "Curtailing", + [settingsActions.miningPool]: "Adding pools", + [settingsActions.coolingMode]: "Setting cooling", + [settingsActions.updateWorkerNames]: "Updating worker names", + [settingsActions.security]: "Updating security", +}; + +export const successMessages: Record = { + [deviceActions.blinkLEDs]: "Blinked LEDs", + [deviceActions.downloadLogs]: "Downloaded logs", + [deviceActions.factoryReset]: "Reset", + [deviceActions.reboot]: "Rebooted", + [deviceActions.shutdown]: "Put to sleep", + [deviceActions.unpair]: "Unpaired", + [deviceActions.wakeUp]: "Woke up", + [deviceActions.firmwareUpdate]: "Firmware installed on", + [performanceActions.managePower]: "Updated power settings for", + [performanceActions.curtail]: "Miners curtailed", + [settingsActions.miningPool]: "Assigned pools to", + [settingsActions.coolingMode]: "Updated cooling mode for", + [settingsActions.rename]: "Miner renamed", + [settingsActions.updateWorkerNames]: "Updated worker names for", + [settingsActions.security]: "Updated security for", + [groupActions.addToGroup]: "Added to group", +}; + +export const failureMessages: Record = { + [deviceActions.blinkLEDs]: "LED blink failed on", + [deviceActions.downloadLogs]: "Log download failed on", + [deviceActions.factoryReset]: "Reset failed on", + [deviceActions.reboot]: "Reboot failed on", + [deviceActions.shutdown]: "Sleep failed on", + [deviceActions.unpair]: "Unpairing failed on", + [deviceActions.wakeUp]: "Wake up failed on", + [deviceActions.firmwareUpdate]: "Firmware update failed on", + [performanceActions.managePower]: "Power update failed on", + [performanceActions.curtail]: "Curtailment failed on", + [settingsActions.miningPool]: "Pool assignment failed on", + [settingsActions.coolingMode]: "Cooling mode update failed on", + [settingsActions.rename]: "Renaming failed on", + [settingsActions.updateWorkerNames]: "Worker name update failed on", + [settingsActions.security]: "Security update failed on", + [groupActions.addToGroup]: "Group assignment failed on", +}; + +export const getLoadingMessage = (action: SupportedAction, subject: string): string => { + if (action === deviceActions.shutdown) return `Putting ${subject} to sleep`; + const message = loadingMessages[action] ?? "Processing"; + return `${message} ${subject}`; +}; + +export const getSuccessMessage = (action: SupportedAction, subject: string): string => { + if (action === deviceActions.shutdown) return `Put ${subject} to sleep`; + const message = successMessages[action] ?? "Completed"; + return `${message} ${subject}`; +}; + +export const getFailureMessage = (action: SupportedAction, context: string): string => { + const message = failureMessages[action] ?? "Action failed on"; + return `${message} ${context}`; +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/index.ts b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/index.ts new file mode 100644 index 000000000..8a3828d53 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/index.ts @@ -0,0 +1,3 @@ +export { default } from "./MinerActionsMenu"; +export { default as SingleMinerActionsMenu } from "./SingleMinerActionsMenu"; +export * from "./RenameOptionsModals"; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/useFleetAuthentication.ts b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/useFleetAuthentication.ts new file mode 100644 index 000000000..c86eb2cdc --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/useFleetAuthentication.ts @@ -0,0 +1,53 @@ +import { useCallback, useState } from "react"; + +interface UseFleetAuthenticationParams { + onAuthenticated: (purpose: "security" | "pool", username: string, password: string) => void; + onDismiss: () => void; +} + +export const useFleetAuthentication = ({ onAuthenticated, onDismiss }: UseFleetAuthenticationParams) => { + const [showAuthenticateFleetModal, setShowAuthenticateFleetModal] = useState(false); + const [authenticationPurpose, setAuthenticationPurpose] = useState<"security" | "pool" | null>(null); + const [fleetCredentials, setFleetCredentials] = useState<{ username: string; password: string } | undefined>( + undefined, + ); + + const startAuthentication = useCallback((purpose: "security" | "pool") => { + setAuthenticationPurpose(purpose); + setShowAuthenticateFleetModal(true); + }, []); + + const handleFleetAuthenticated = useCallback( + (username: string, password: string) => { + setFleetCredentials({ username, password }); + setShowAuthenticateFleetModal(false); + if (authenticationPurpose) { + onAuthenticated(authenticationPurpose, username, password); + } + }, + [authenticationPurpose, onAuthenticated], + ); + + const handleAuthDismiss = useCallback(() => { + setShowAuthenticateFleetModal(false); + setAuthenticationPurpose(null); + setFleetCredentials(undefined); + onDismiss(); + }, [onDismiss]); + + const resetAuthState = useCallback(() => { + setShowAuthenticateFleetModal(false); + setAuthenticationPurpose(null); + setFleetCredentials(undefined); + }, []); + + return { + showAuthenticateFleetModal, + authenticationPurpose, + fleetCredentials, + startAuthentication, + handleFleetAuthenticated, + handleAuthDismiss, + resetAuthState, + }; +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/useManageSecurityFlow.ts b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/useManageSecurityFlow.ts new file mode 100644 index 000000000..975c6f12b --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/useManageSecurityFlow.ts @@ -0,0 +1,340 @@ +import { useCallback, useState } from "react"; +import { create } from "@bufbuild/protobuf"; +import { getLoadingMessage, minersMessage, settingsActions, SupportedAction } from "./constants"; +import { type MinerGroup } from "./ManageSecurity"; +import { + type MinerListFilter, + type MinerModelGroup, + type MinerStateSnapshot, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { + DeviceFilterSchema, + DeviceSelector, + DeviceSelectorSchema, + UpdateMinerPasswordResponse, +} from "@/protoFleet/api/generated/minercommand/v1/command_pb"; +import { minerTypes } from "@/protoFleet/features/fleetManagement/components/MinerList/constants"; +import { createDeviceSelector } from "@/protoFleet/features/fleetManagement/utils/deviceSelector"; +import { type SelectionMode } from "@/shared/components/List"; +import { pushToast, STATUSES as TOAST_STATUSES } from "@/shared/features/toaster"; + +type PendingActionCallback = (filteredSelector?: DeviceSelector, filteredDeviceIds?: string[]) => void; + +function groupMinersByModel(deviceIds: string[], miners: Record): MinerGroup[] { + const groupMap = new Map(); + + deviceIds.forEach((id) => { + const miner = miners[id]; + if (!miner) return; + + const manufacturer = miner.manufacturer || ""; + const model = miner.model || "Unknown Model"; + const key = `${manufacturer}-${model}`; + + if (!groupMap.has(key)) { + groupMap.set(key, { + name: miner.name || model, + model, + manufacturer, + count: 0, + deviceIdentifiers: [], + status: "pending", + }); + } + + const group = groupMap.get(key)!; + group.count++; + group.deviceIdentifiers.push(id); + }); + + return Array.from(groupMap.values()); +} + +function updateGroupsAfterBatch( + prev: MinerGroup[], + groupSnapshot: MinerGroup, + successIds: string[], + failureIds: string[], +): MinerGroup[] { + const rest = prev.filter( + (g) => + !(g.manufacturer === groupSnapshot.manufacturer && g.model === groupSnapshot.model && g.status === "loading"), + ); + + if (successIds.length > 0 && failureIds.length > 0) { + return [ + ...rest, + { ...groupSnapshot, deviceIdentifiers: successIds, count: successIds.length, status: "updated" as const }, + { ...groupSnapshot, deviceIdentifiers: failureIds, count: failureIds.length, status: "pending" as const }, + ]; + } + if (successIds.length > 0) { + return [...rest, { ...groupSnapshot, status: "updated" as const }]; + } + return [...rest, { ...groupSnapshot, status: "failed" as const }]; +} + +export interface SecurityActionsProps { + showAuthenticateFleetModal: boolean; + authenticationPurpose: "security" | "pool" | null; + showUpdatePasswordModal: boolean; + hasThirdPartyMiners: boolean; + handleFleetAuthenticated: (username: string, password: string) => void; + handlePasswordConfirm: (currentPassword: string, newPassword: string) => void; + handlePasswordDismiss: () => void; + handleAuthDismiss: () => void; + showManageSecurityModal: boolean; + minerGroups: MinerGroup[]; + handleUpdateGroup: (group: MinerGroup) => void; + handleSecurityModalClose: () => void; +} + +interface UseManageSecurityFlowParams { + deviceIdentifiers: string[]; + selectionMode: SelectionMode; + getMinerModelGroups: (filter: MinerListFilter | null) => Promise; + withCapabilityCheck: (action: SupportedAction, onProceed: PendingActionCallback) => Promise; + updateMinerPassword: (params: { + deviceSelector: DeviceSelector; + newPassword: string; + currentPassword: string; + userUsername: string; + userPassword: string; + onSuccess: (value: UpdateMinerPasswordResponse) => void; + onError?: (error: string) => void; + }) => void; + startBatchOperation: (batch: { + batchIdentifier: string; + action: SupportedAction; + deviceIdentifiers: string[]; + }) => void; + handleSuccess: ( + action: SupportedAction, + originalToastId: number, + batchIdentifier: string, + onBatchComplete?: (successDeviceIds: string[], failureDeviceIds: string[]) => void, + ) => void; + handleError: (toastId: number, error: string) => void; + onActionComplete?: () => void; + setCurrentAction: (action: SupportedAction | null) => void; + fleetCredentials: { username: string; password: string } | undefined; + resetAuthState: () => void; + miners?: Record; + currentFilter?: MinerListFilter; +} + +export const useManageSecurityFlow = ({ + deviceIdentifiers, + selectionMode, + getMinerModelGroups, + withCapabilityCheck, + updateMinerPassword, + startBatchOperation, + handleSuccess, + handleError, + onActionComplete, + setCurrentAction, + fleetCredentials, + resetAuthState, + miners = {} as Record, + currentFilter, +}: UseManageSecurityFlowParams) => { + const [showUpdatePasswordModal, setShowUpdatePasswordModal] = useState(false); + const [securityFilteredDeviceIds, setSecurityFilteredDeviceIds] = useState(undefined); + const [hasThirdPartyMiners, setHasThirdPartyMiners] = useState(false); + const [showManageSecurityModal, setShowManageSecurityModal] = useState(false); + const [minerGroups, setMinerGroups] = useState([]); + const [currentGroupForUpdate, setCurrentGroupForUpdate] = useState(null); + + // Resets security-specific state before starting the auth flow. + const startManageSecurity = useCallback(() => { + setSecurityFilteredDeviceIds(undefined); + setCurrentAction(settingsActions.security); + }, [setCurrentAction]); + + const openSecurityModalViaCapabilityCheck = useCallback(async () => { + await withCapabilityCheck(settingsActions.security, (_filteredSelector, filteredDeviceIds) => { + const deviceIdsToUse = filteredDeviceIds ?? deviceIdentifiers; + setSecurityFilteredDeviceIds(filteredDeviceIds); + setCurrentAction(settingsActions.security); + setMinerGroups(groupMinersByModel(deviceIdsToUse, miners)); + setShowManageSecurityModal(true); + }); + }, [withCapabilityCheck, deviceIdentifiers, setCurrentAction, miners]); + + // Called by useMinerActions once fleet auth completes with purpose="security". + // Credentials are not needed here — they're read from the fleetCredentials param at confirm time. + const handleSecurityAuthenticated = useCallback( + async (_username: string, _password: string) => { + if (selectionMode === "all") { + // For "all" selection, query backend for accurate model groups across the full fleet + try { + const groups = await getMinerModelGroups(currentFilter ?? null); + setMinerGroups( + groups.map((g) => { + const isProto = g.manufacturer.toLowerCase() === minerTypes.protoRig; + return { + name: isProto ? `${g.manufacturer} ${g.model}`.trim() : g.model, + model: g.model, + manufacturer: g.manufacturer, + count: g.count, + deviceIdentifiers: [], + status: "pending" as const, + }; + }), + ); + setShowManageSecurityModal(true); + } catch { + await openSecurityModalViaCapabilityCheck(); + } + } else { + await openSecurityModalViaCapabilityCheck(); + } + }, + [selectionMode, getMinerModelGroups, openSecurityModalViaCapabilityCheck, currentFilter], + ); + + const handleUpdateGroup = useCallback((group: MinerGroup) => { + setCurrentGroupForUpdate(group); + setHasThirdPartyMiners(group.manufacturer.toLowerCase() !== minerTypes.protoRig); + setShowUpdatePasswordModal(true); + }, []); + + const handleSecurityModalClose = useCallback(() => { + setShowManageSecurityModal(false); + setMinerGroups([]); + setSecurityFilteredDeviceIds(undefined); + setCurrentAction(null); + resetAuthState(); + onActionComplete?.(); + }, [setCurrentAction, resetAuthState, onActionComplete]); + + const handlePasswordConfirm = useCallback( + (currentPassword: string, newPassword: string) => { + let selectorToUse: DeviceSelector; + let deviceIdsToUse: string[]; + + if (selectionMode === "all" && currentGroupForUpdate) { + // For "all" selection, use a model-scoped all_devices selector so the command + // targets every fleet miner of this model, not just the visible page. + // Note: error_component_types filter has no equivalent in DeviceFilter and is not applied here. + selectorToUse = create(DeviceSelectorSchema, { + selectionType: { + case: "allDevices", + value: create(DeviceFilterSchema, { + models: [currentGroupForUpdate.model], + ...(currentGroupForUpdate.manufacturer ? { manufacturers: [currentGroupForUpdate.manufacturer] } : {}), + deviceStatus: currentFilter?.deviceStatus ?? [], + pairingStatus: currentFilter?.pairingStatuses ?? [], + }), + }, + }); + deviceIdsToUse = currentGroupForUpdate.deviceIdentifiers; + } else { + const rawDeviceIds = currentGroupForUpdate + ? currentGroupForUpdate.deviceIdentifiers + : (securityFilteredDeviceIds ?? deviceIdentifiers); + selectorToUse = createDeviceSelector("subset", rawDeviceIds); + deviceIdsToUse = rawDeviceIds; + } + + if (!fleetCredentials) return; + + setShowUpdatePasswordModal(false); + + const id = pushToast({ + message: getLoadingMessage(settingsActions.security, minersMessage), + status: TOAST_STATUSES.loading, + longRunning: true, + onClose: () => onActionComplete?.(), + }); + + updateMinerPassword({ + deviceSelector: selectorToUse, + newPassword, + currentPassword, + userUsername: fleetCredentials.username, + userPassword: fleetCredentials.password, + onSuccess: (value: UpdateMinerPasswordResponse) => { + startBatchOperation({ + batchIdentifier: value.batchIdentifier, + action: settingsActions.security, + deviceIdentifiers: deviceIdsToUse, + }); + + const groupSnapshot = currentGroupForUpdate; + if (groupSnapshot) { + setMinerGroups((prev) => prev.map((g) => (g === groupSnapshot ? { ...g, status: "loading" as const } : g))); + } + + handleSuccess( + settingsActions.security, + id, + value.batchIdentifier, + groupSnapshot + ? (successIds, failureIds) => { + setMinerGroups((prev) => updateGroupsAfterBatch(prev, groupSnapshot, successIds, failureIds)); + setCurrentGroupForUpdate(null); + } + : () => onActionComplete?.(), + ); + }, + onError: (error: string) => { + handleError(id, error); + + if (currentGroupForUpdate) { + setMinerGroups((prev) => + prev.map((g) => (g === currentGroupForUpdate ? { ...g, status: "failed" as const } : g)), + ); + setCurrentGroupForUpdate(null); + } else { + onActionComplete?.(); + } + }, + }); + + setCurrentAction(null); + }, + [ + selectionMode, + currentGroupForUpdate, + securityFilteredDeviceIds, + deviceIdentifiers, + fleetCredentials, + updateMinerPassword, + handleSuccess, + handleError, + onActionComplete, + startBatchOperation, + setCurrentAction, + currentFilter, + ], + ); + + const handlePasswordDismiss = useCallback(() => { + setShowUpdatePasswordModal(false); + setCurrentGroupForUpdate(null); + + if (showManageSecurityModal) { + return; + } + + setSecurityFilteredDeviceIds(undefined); + resetAuthState(); + setCurrentAction(null); + onActionComplete?.(); + }, [showManageSecurityModal, setCurrentAction, resetAuthState, onActionComplete]); + + return { + showManageSecurityModal, + showUpdatePasswordModal, + hasThirdPartyMiners, + minerGroups, + startManageSecurity, + handleSecurityAuthenticated, + handleUpdateGroup, + handleSecurityModalClose, + handlePasswordConfirm, + handlePasswordDismiss, + }; +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/useMinerActions.test.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/useMinerActions.test.tsx new file mode 100644 index 000000000..ffc9f44bd --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/useMinerActions.test.tsx @@ -0,0 +1,3613 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { create as createProto } from "@bufbuild/protobuf"; +import { deviceActions, performanceActions, settingsActions, type SupportedAction } from "./constants"; +import { useMinerActions } from "./useMinerActions"; +import { CoolingMode } from "@/protoFleet/api/generated/common/v1/cooling_pb"; +import { + MinerListFilterSchema, + type MinerStateSnapshot, + MinerStateSnapshotSchema, + PairingStatus, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { PerformanceMode } from "@/protoFleet/api/generated/minercommand/v1/command_pb"; +import { DeviceStatus } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { Settings } from "@/shared/assets/icons"; +import * as toaster from "@/shared/features/toaster"; + +// Create mock functions at module level +const mockStartBatchOperation = vi.fn(); +const mockCompleteBatchOperation = vi.fn(); +const mockRemoveDevicesFromBatch = vi.fn(); +const mockStreamCommandBatchUpdates = vi.fn((_params: any) => Promise.resolve()); +const mockStartMining = vi.fn(); +const mockStopMining = vi.fn(); +const mockBlinkLED = vi.fn(); +const mockDeleteMiners = vi.fn(); +const mockReboot = vi.fn(); +const mockSetPowerTarget = vi.fn(); +const mockSetCoolingMode = vi.fn(); +const mockUpdateMinerPassword = vi.fn(); +const mockGetMinerModelGroups = vi.fn(); +const mockDownloadLogs = vi.fn(); +const mockGetCommandBatchLogBundle = vi.fn(); +const mockRenameSingleMiner = vi.fn(); +const mockCheckCommandCapabilities = vi.fn(({ onSuccess }) => { + // Default to all supported (no modal shown) + onSuccess({ + allSupported: true, + noneSupported: false, + supportedCount: 1, + unsupportedCount: 0, + totalCount: 1, + unsupportedGroups: [], + supportedDeviceIdentifiers: [], + }); +}); + +// Mock dependencies +vi.mock("@/protoFleet/api/useMinerCommand", () => ({ + useMinerCommand: () => ({ + startMining: mockStartMining, + stopMining: mockStopMining, + blinkLED: mockBlinkLED, + deleteMiners: mockDeleteMiners, + reboot: mockReboot, + streamCommandBatchUpdates: mockStreamCommandBatchUpdates, + setPowerTarget: mockSetPowerTarget, + setCoolingMode: mockSetCoolingMode, + checkCommandCapabilities: mockCheckCommandCapabilities, + updateMinerPassword: mockUpdateMinerPassword, + downloadLogs: mockDownloadLogs, + firmwareUpdate: vi.fn(), + getCommandBatchLogBundle: mockGetCommandBatchLogBundle, + }), +})); + +const mockFetchCoolingMode = vi.fn(() => Promise.resolve(0)); // CoolingMode.UNSPECIFIED +vi.mock("@/protoFleet/api/useMinerCoolingMode", () => ({ + default: () => ({ + fetchCoolingMode: mockFetchCoolingMode, + }), +})); + +vi.mock("@/protoFleet/api/useRenameMiners", () => ({ + default: () => ({ + renameSingleMiner: mockRenameSingleMiner, + }), +})); + +vi.mock("@/protoFleet/api/useMinerModelGroups", () => ({ + default: () => ({ + getMinerModelGroups: mockGetMinerModelGroups, + }), +})); + +vi.mock("@/protoFleet/store", () => ({ + useFleetStore: vi.fn(), + useAuthErrors: () => ({ + handleAuthErrors: vi.fn(({ onError }) => onError?.()), + }), +})); + +vi.mock("@/shared/features/toaster", () => ({ + pushToast: vi.fn(() => 1), + updateToast: vi.fn(), + removeToast: vi.fn(), + STATUSES: { + success: "success", + error: "error", + loading: "loading", + }, +})); + +describe("useMinerActions", () => { + let testMiners: Record; + + /** Shared batch-ops & miners params injected into every useMinerActions call. */ + const batchOpsParams = () => ({ + startBatchOperation: mockStartBatchOperation, + completeBatchOperation: mockCompleteBatchOperation, + removeDevicesFromBatch: mockRemoveDevicesFromBatch, + miners: testMiners, + }); + + beforeEach(async () => { + vi.clearAllMocks(); + mockGetMinerModelGroups.mockResolvedValue([]); + testMiners = {}; + }); + + describe("Basic hook initialization", () => { + it("should initialize with correct default values", () => { + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.ONLINE }, + ], + selectionMode: "subset", + totalCount: 2, + }), + ); + + expect(result.current.currentAction).toBeNull(); + expect(result.current.numberOfMiners).toBe(2); + expect(result.current.showManagePowerModal).toBe(false); + expect(result.current.popoverActions).toBeDefined(); + expect(result.current.popoverActions.length).toBeGreaterThan(0); + }); + + it("should calculate displayCount correctly for 'all' selection mode", () => { + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1" }, { deviceIdentifier: "device-2" }], + selectionMode: "all", + totalCount: 100, + }), + ); + + const sleepAction = result.current.popoverActions.find((a) => a.action === deviceActions.shutdown); + expect(sleepAction?.confirmation?.title).toContain("100"); + }); + + it("should calculate displayCount correctly for 'subset' selection mode", () => { + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1" }, { deviceIdentifier: "device-2" }], + selectionMode: "subset", + totalCount: 100, + }), + ); + + const sleepAction = result.current.popoverActions.find((a) => a.action === deviceActions.shutdown); + expect(sleepAction?.confirmation?.title).toContain("2"); + }); + + it("should include all expected actions in popoverActions", () => { + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + const actions = result.current.popoverActions.map((a) => a.action); + + expect(actions).toContain(deviceActions.blinkLEDs); + expect(actions).toContain(deviceActions.reboot); + expect(actions).toContain(deviceActions.shutdown); + expect(actions).toContain(deviceActions.unpair); + expect(actions).toContain(deviceActions.firmwareUpdate); + expect(actions).toContain(performanceActions.managePower); + expect(actions).toContain(settingsActions.miningPool); + expect(actions).toContain(settingsActions.coolingMode); + expect(actions).not.toContain(settingsActions.rename); + }); + }); + + describe("Power state actions", () => { + it("should show both sleep and wake up actions for bulk selection with mixed status", () => { + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.INACTIVE }, + ], + selectionMode: "subset", + }), + ); + + const sleepAction = result.current.popoverActions.find((a) => a.action === deviceActions.shutdown); + const wakeUpAction = result.current.popoverActions.find((a) => a.action === deviceActions.wakeUp); + + expect(sleepAction).toBeDefined(); + expect(wakeUpAction).toBeDefined(); + }); + + it("should show only wake up action for single inactive device", () => { + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.INACTIVE }], + selectionMode: "subset", + }), + ); + + const actions = result.current.popoverActions.map((a) => a.action); + + expect(actions).not.toContain(deviceActions.shutdown); + expect(actions).toContain(deviceActions.wakeUp); + }); + + it("should show only sleep action for single active device", () => { + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + const actions = result.current.popoverActions.map((a) => a.action); + + expect(actions).toContain(deviceActions.shutdown); + expect(actions).not.toContain(deviceActions.wakeUp); + }); + + it("should show both actions when device status is undefined (bulk with different statuses)", () => { + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.ERROR }, + ], + selectionMode: "subset", + }), + ); + + const actions = result.current.popoverActions.map((a) => a.action); + + expect(actions).toContain(deviceActions.shutdown); + expect(actions).toContain(deviceActions.wakeUp); + }); + }); + + describe("Action handlers - Setting current action", () => { + it("should set currentAction when reboot action handler is called", async () => { + const onActionStart = vi.fn(); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + onActionStart, + }), + ); + + const rebootAction = result.current.popoverActions.find((a) => a.action === deviceActions.reboot); + + await act(async () => { + await rebootAction?.actionHandler(); + }); + + expect(result.current.currentAction).toBe(deviceActions.reboot); + expect(onActionStart).toHaveBeenCalled(); + }); + + it("should set currentAction when shutdown action handler is called", async () => { + const onActionStart = vi.fn(); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + onActionStart, + }), + ); + + const shutdownAction = result.current.popoverActions.find((a) => a.action === deviceActions.shutdown); + + await act(async () => { + await shutdownAction?.actionHandler(); + }); + + expect(result.current.currentAction).toBe(deviceActions.shutdown); + expect(onActionStart).toHaveBeenCalled(); + }); + + it("should set currentAction when wake up action handler is called", async () => { + const onActionStart = vi.fn(); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.INACTIVE }], + selectionMode: "subset", + onActionStart, + }), + ); + + const wakeUpAction = result.current.popoverActions.find((a) => a.action === deviceActions.wakeUp); + + await act(async () => { + await wakeUpAction?.actionHandler(); + }); + + expect(result.current.currentAction).toBe(deviceActions.wakeUp); + expect(onActionStart).toHaveBeenCalled(); + }); + + it("should set currentAction when unpair action handler is called", () => { + const onActionStart = vi.fn(); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + onActionStart, + }), + ); + + const deleteAction = result.current.popoverActions.find((a) => a.action === deviceActions.unpair); + + act(() => { + deleteAction?.actionHandler(); + }); + + expect(result.current.currentAction).toBe(deviceActions.unpair); + expect(onActionStart).toHaveBeenCalled(); + }); + + it("should show authentication modal when mining pool action handler is called", async () => { + const onActionStart = vi.fn(); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + onActionStart, + }), + ); + + const poolAction = result.current.popoverActions.find((a) => a.action === settingsActions.miningPool); + + await act(async () => { + await poolAction?.actionHandler(); + }); + + expect(result.current.showAuthenticateFleetModal).toBe(true); + expect(result.current.currentAction).toBe(settingsActions.miningPool); + expect(onActionStart).toHaveBeenCalled(); + }); + }); + + describe("Blink LEDs action (immediate execution, no confirmation)", () => { + it("should call blinkLED API when blink action handler is called", () => { + mockBlinkLED.mockImplementation(({ onSuccess }: any) => { + onSuccess({ batchIdentifier: "batch-blink" }); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + const blinkAction = result.current.popoverActions.find((a) => a.action === deviceActions.blinkLEDs); + + act(() => { + blinkAction?.actionHandler(); + }); + + expect(mockBlinkLED).toHaveBeenCalled(); + expect(mockStartBatchOperation).toHaveBeenCalledWith({ + batchIdentifier: "batch-blink", + action: deviceActions.blinkLEDs, + deviceIdentifiers: ["device-1"], + }); + }); + + it("should push loading toast when blink action is triggered", () => { + mockBlinkLED.mockImplementation(({ onSuccess }: any) => { + onSuccess({ batchIdentifier: "batch-blink" }); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + const blinkAction = result.current.popoverActions.find((a) => a.action === deviceActions.blinkLEDs); + + act(() => { + blinkAction?.actionHandler(); + }); + + expect(toaster.pushToast).toHaveBeenCalledWith({ + message: "Blinking LEDs", + status: toaster.STATUSES.loading, + longRunning: true, + onClose: expect.any(Function), + }); + }); + }); + + describe("Action-specific failure toast messages", () => { + it("should show action-specific failure toast for blink LEDs partial failure", async () => { + mockBlinkLED.mockImplementation(({ onSuccess }: any) => { + onSuccess({ batchIdentifier: "batch-blink" }); + }); + mockStreamCommandBatchUpdates.mockImplementation(({ onStreamData }: any) => { + onStreamData({ + status: { + commandBatchDeviceCount: { + total: BigInt(2), + success: BigInt(1), + failure: BigInt(1), + successDeviceIdentifiers: ["device-1"], + failureDeviceIdentifiers: ["device-2"], + }, + }, + }); + return Promise.resolve(); + }); + + const { result } = renderHook(() => + useMinerActions({ + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.ONLINE }, + ], + selectionMode: "subset", + }), + ); + + const blinkAction = result.current.popoverActions.find((a) => a.action === deviceActions.blinkLEDs); + await act(async () => { + blinkAction?.actionHandler(); + }); + + expect(toaster.pushToast).toHaveBeenCalledWith( + expect.objectContaining({ + message: "LED blink failed on 1 out of 2 miners", + status: toaster.STATUSES.error, + }), + ); + }); + + it("should show action-specific failure toast for reboot partial failure", async () => { + mockReboot.mockImplementation(({ onSuccess }: any) => { + onSuccess({ batchIdentifier: "batch-reboot" }); + }); + mockStreamCommandBatchUpdates.mockImplementation(({ onStreamData }: any) => { + onStreamData({ + status: { + commandBatchDeviceCount: { + total: BigInt(2), + success: BigInt(1), + failure: BigInt(1), + successDeviceIdentifiers: ["device-1"], + failureDeviceIdentifiers: ["device-2"], + }, + }, + }); + return Promise.resolve(); + }); + + const { result } = renderHook(() => + useMinerActions({ + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.ONLINE }, + ], + selectionMode: "subset", + }), + ); + + const rebootAction = result.current.popoverActions.find((a) => a.action === deviceActions.reboot); + await act(async () => { + await rebootAction?.actionHandler(); + }); + await act(async () => { + await result.current.handleConfirmation(); + }); + + expect(toaster.pushToast).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Reboot failed on 1 out of 2 miners", + status: toaster.STATUSES.error, + }), + ); + }); + }); + + describe("Retry action on partial failure", () => { + type RenderHookResult = ReturnType, unknown>>["result"]; + + type RetryCase = { + name: string; + batchId: string; + deviceStatus: DeviceStatus; + mock: ReturnType; + dispatch: (result: RenderHookResult) => Promise; + getRetryDeviceIdentifiers: (mockCall: any) => string[]; + }; + + const readSubsetIdsFromRequestArg = (requestKey: string) => (mockCall: any) => + mockCall[0][requestKey].deviceSelector.selectionType.value.deviceIdentifiers; + const readSubsetIdsFromDirectSelector = (mockCall: any) => + mockCall[0].deviceSelector.selectionType.value.deviceIdentifiers; + + const runConfirmFlow = (action: SupportedAction) => async (result: RenderHookResult) => { + const popoverAction = result.current.popoverActions.find((a) => a.action === action); + await act(async () => { + await popoverAction?.actionHandler(); + }); + await act(async () => { + await result.current.handleConfirmation(); + }); + }; + + const runModalFlow = + (action: SupportedAction, confirm: (result: RenderHookResult) => void) => async (result: RenderHookResult) => { + const popoverAction = result.current.popoverActions.find((a) => a.action === action); + await act(async () => { + await popoverAction?.actionHandler(); + }); + await act(async () => { + confirm(result); + }); + }; + + const retryCases: RetryCase[] = [ + { + name: "reboot", + batchId: "batch-reboot", + deviceStatus: DeviceStatus.ONLINE, + mock: mockReboot, + dispatch: runConfirmFlow(deviceActions.reboot), + getRetryDeviceIdentifiers: readSubsetIdsFromRequestArg("rebootRequest"), + }, + { + name: "shutdown", + batchId: "batch-shutdown", + deviceStatus: DeviceStatus.ONLINE, + mock: mockStopMining, + dispatch: runConfirmFlow(deviceActions.shutdown), + getRetryDeviceIdentifiers: readSubsetIdsFromRequestArg("stopMiningRequest"), + }, + { + name: "wakeUp", + batchId: "batch-wakeup", + deviceStatus: DeviceStatus.INACTIVE, + mock: mockStartMining, + dispatch: runConfirmFlow(deviceActions.wakeUp), + getRetryDeviceIdentifiers: readSubsetIdsFromRequestArg("startMiningRequest"), + }, + { + name: "blinkLEDs", + batchId: "batch-blink", + deviceStatus: DeviceStatus.ONLINE, + mock: mockBlinkLED, + dispatch: async (result) => { + const popoverAction = result.current.popoverActions.find((a) => a.action === deviceActions.blinkLEDs); + await act(async () => { + popoverAction?.actionHandler(); + }); + }, + getRetryDeviceIdentifiers: readSubsetIdsFromRequestArg("blinkLEDRequest"), + }, + { + name: "managePower", + batchId: "batch-power", + deviceStatus: DeviceStatus.ONLINE, + mock: mockSetPowerTarget, + dispatch: runModalFlow(performanceActions.managePower, (result) => + result.current.handleManagePowerConfirm(PerformanceMode.MAXIMUM_HASHRATE), + ), + getRetryDeviceIdentifiers: readSubsetIdsFromDirectSelector, + }, + { + name: "coolingMode", + batchId: "batch-cooling", + deviceStatus: DeviceStatus.ONLINE, + mock: mockSetCoolingMode, + dispatch: runModalFlow(settingsActions.coolingMode, (result) => + result.current.handleCoolingModeConfirm(CoolingMode.AIR_COOLED), + ), + getRetryDeviceIdentifiers: readSubsetIdsFromDirectSelector, + }, + ]; + + const stubPartialFailureStream = (successIds: string[], failureIds: string[]) => { + mockStreamCommandBatchUpdates.mockImplementation(({ onStreamData }: any) => { + onStreamData({ + status: { + commandBatchDeviceCount: { + total: BigInt(successIds.length + failureIds.length), + success: BigInt(successIds.length), + failure: BigInt(failureIds.length), + successDeviceIdentifiers: successIds, + failureDeviceIdentifiers: failureIds, + }, + }, + }); + return Promise.resolve(); + }); + }; + + const stubActionSuccess = (mock: ReturnType, batchId: string) => { + mock.mockImplementation(({ onSuccess }: any) => { + onSuccess({ batchIdentifier: batchId }); + }); + }; + + const renderFor = (deviceStatus: DeviceStatus) => + renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus }, + { deviceIdentifier: "device-2", deviceStatus }, + ], + selectionMode: "subset", + }), + ); + + const findRetryCall = () => { + const updateCalls = (toaster.updateToast as ReturnType).mock.calls; + return updateCalls.find((call) => call[1]?.actions?.[0]?.label === "Retry"); + }; + + it.each(retryCases)( + "attaches Retry to the error toast after $name partial failure", + async ({ batchId, deviceStatus, mock, dispatch }) => { + stubActionSuccess(mock, batchId); + stubPartialFailureStream(["device-1"], ["device-2"]); + + const { result } = renderFor(deviceStatus); + await dispatch(result); + + expect(findRetryCall()).toBeDefined(); + }, + ); + + it.each(retryCases)( + "retries $name with only failed device IDs and carries onClose when clicked", + async ({ batchId, deviceStatus, mock, dispatch, getRetryDeviceIdentifiers }) => { + stubActionSuccess(mock, batchId); + stubPartialFailureStream(["device-1"], ["device-2"]); + + const { result } = renderFor(deviceStatus); + await dispatch(result); + + const retryCall = findRetryCall(); + if (!retryCall) throw new Error("Retry action was not attached"); + const retryOnClick = retryCall[1].actions[0].onClick; + + mock.mockClear(); + (toaster.pushToast as ReturnType).mockClear(); + stubActionSuccess(mock, `${batchId}-retry`); + mockStreamCommandBatchUpdates.mockImplementation(() => Promise.resolve()); + + // Clicking Retry twice rapidly must only dispatch once (I2 guard). + await act(async () => { + retryOnClick(); + retryOnClick(); + }); + + expect(mock).toHaveBeenCalledTimes(1); + expect(getRetryDeviceIdentifiers(mock.mock.calls[0])).toEqual(["device-2"]); + + // Retry loading toast must carry onClose so onActionComplete fires on + // dismissal (L1 regression guard). + const pushCalls = (toaster.pushToast as ReturnType).mock.calls; + const retryPushCall = pushCalls[pushCalls.length - 1]; + expect(retryPushCall?.[0]).toEqual(expect.objectContaining({ onClose: expect.any(Function) })); + }, + ); + + it("does not attach Retry when all devices succeed", async () => { + stubActionSuccess(mockReboot, "batch-reboot"); + stubPartialFailureStream(["device-1", "device-2"], []); + + const { result } = renderFor(DeviceStatus.ONLINE); + await runConfirmFlow(deviceActions.reboot)(result); + + expect(findRetryCall()).toBeUndefined(); + }); + + // L3: all-fail path goes through removeToast(originalToastId) (not update) + // before attaching Retry. This exercises that branch and confirms Retry is + // still offered (streamCompletedNormally is true when 0 + N === N). + it("attaches Retry when all devices fail", async () => { + stubActionSuccess(mockReboot, "batch-reboot"); + stubPartialFailureStream([], ["device-1", "device-2"]); + + const { result } = renderFor(DeviceStatus.ONLINE); + await runConfirmFlow(deviceActions.reboot)(result); + + expect(findRetryCall()).toBeDefined(); + }); + + // L2: verify the error toast is still pushed on premature termination, + // even though Retry is suppressed. A regression that accidentally + // suppressed the error toast would be caught here. + it("does not attach Retry but still shows error toast when the batch stream ends prematurely", async () => { + stubActionSuccess(mockReboot, "batch-reboot"); + mockStreamCommandBatchUpdates.mockImplementation(({ onStreamData }: any) => { + onStreamData({ + status: { + commandBatchDeviceCount: { + total: BigInt(3), + success: BigInt(0), + failure: BigInt(1), + successDeviceIdentifiers: [], + failureDeviceIdentifiers: ["device-1"], + }, + }, + }); + return Promise.resolve(); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-3", deviceStatus: DeviceStatus.ONLINE }, + ], + selectionMode: "subset", + }), + ); + await runConfirmFlow(deviceActions.reboot)(result); + + expect(findRetryCall()).toBeUndefined(); + expect(toaster.pushToast).toHaveBeenCalledWith(expect.objectContaining({ status: toaster.STATUSES.error })); + }); + }); + + describe("Modal interactions", () => { + it("should open manage power modal when action handler is called", async () => { + const onActionStart = vi.fn(); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + onActionStart, + }), + ); + + const managePowerAction = result.current.popoverActions.find((a) => a.action === performanceActions.managePower); + + await act(async () => { + await managePowerAction?.actionHandler(); + }); + + expect(result.current.showManagePowerModal).toBe(true); + expect(result.current.currentAction).toBe(performanceActions.managePower); + expect(onActionStart).toHaveBeenCalled(); + }); + + it("should handle manage power confirm and call API", async () => { + mockSetPowerTarget.mockImplementation(({ onSuccess }: any) => { + onSuccess({ batchIdentifier: "batch-power" }); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + // Open modal first + const managePowerAction = result.current.popoverActions.find((a) => a.action === performanceActions.managePower); + + await act(async () => { + await managePowerAction?.actionHandler(); + }); + + // Confirm with performance mode + act(() => { + result.current.handleManagePowerConfirm(PerformanceMode.MAXIMUM_HASHRATE); + }); + + expect(result.current.showManagePowerModal).toBe(false); + expect(result.current.currentAction).toBeNull(); + expect(mockSetPowerTarget).toHaveBeenCalled(); + }); + + it("should handle manage power dismiss", async () => { + const onActionComplete = vi.fn(); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + onActionComplete, + }), + ); + + // Open modal first + const managePowerAction = result.current.popoverActions.find((a) => a.action === performanceActions.managePower); + + await act(async () => { + await managePowerAction?.actionHandler(); + }); + + // Dismiss modal + act(() => { + result.current.handleManagePowerDismiss(); + }); + + expect(result.current.showManagePowerModal).toBe(false); + expect(result.current.currentAction).toBeNull(); + expect(onActionComplete).toHaveBeenCalled(); + }); + + it("should open cooling mode modal and fetch current mode for single miner", async () => { + const onActionStart = vi.fn(); + mockFetchCoolingMode.mockResolvedValueOnce(CoolingMode.AIR_COOLED); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + onActionStart, + }), + ); + + const coolingModeAction = result.current.popoverActions.find((a) => a.action === settingsActions.coolingMode); + + await act(async () => { + await coolingModeAction?.actionHandler(); + }); + + expect(result.current.showCoolingModeModal).toBe(true); + expect(result.current.currentAction).toBe(settingsActions.coolingMode); + expect(onActionStart).toHaveBeenCalled(); + expect(mockFetchCoolingMode).toHaveBeenCalledWith("device-1"); + expect(result.current.currentCoolingMode).toBe(CoolingMode.AIR_COOLED); + }); + + it("should not fetch cooling mode for multi-miner selection", async () => { + mockFetchCoolingMode.mockClear(); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.ONLINE }, + ], + selectionMode: "subset", + }), + ); + + const coolingModeAction = result.current.popoverActions.find((a) => a.action === settingsActions.coolingMode); + + await act(async () => { + await coolingModeAction?.actionHandler(); + }); + + expect(result.current.showCoolingModeModal).toBe(true); + expect(mockFetchCoolingMode).not.toHaveBeenCalled(); + expect(result.current.currentCoolingMode).toBeUndefined(); + }); + + it("should handle cooling mode confirm and call API", async () => { + mockSetCoolingMode.mockImplementation(({ onSuccess }: any) => { + onSuccess({ batchIdentifier: "batch-cooling" }); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + // Open modal first + const coolingModeAction = result.current.popoverActions.find((a) => a.action === settingsActions.coolingMode); + + await act(async () => { + await coolingModeAction?.actionHandler(); + }); + + // Confirm with cooling mode + act(() => { + result.current.handleCoolingModeConfirm(CoolingMode.AIR_COOLED); + }); + + expect(result.current.showCoolingModeModal).toBe(false); + expect(result.current.currentAction).toBeNull(); + expect(mockSetCoolingMode).toHaveBeenCalled(); + expect(mockStartBatchOperation).toHaveBeenCalledWith({ + batchIdentifier: "batch-cooling", + action: settingsActions.coolingMode, + deviceIdentifiers: ["device-1"], + }); + }); + + it("should handle cooling mode dismiss", async () => { + const onActionComplete = vi.fn(); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + onActionComplete, + }), + ); + + // Open modal first + const coolingModeAction = result.current.popoverActions.find((a) => a.action === settingsActions.coolingMode); + + await act(async () => { + await coolingModeAction?.actionHandler(); + }); + + // Dismiss modal + act(() => { + result.current.handleCoolingModeDismiss(); + }); + + expect(result.current.showCoolingModeModal).toBe(false); + expect(result.current.currentAction).toBeNull(); + expect(onActionComplete).toHaveBeenCalled(); + }); + + it("should use filtered device selector for cooling mode when unsupported miners exist", async () => { + mockSetCoolingMode.mockImplementation(({ onSuccess }: any) => { + onSuccess({ batchIdentifier: "batch-cooling-filtered" }); + }); + + // First call returns partial support (triggers unsupported miners modal) + mockCheckCommandCapabilities.mockImplementationOnce(({ onSuccess }: any) => { + onSuccess({ + allSupported: false, + noneSupported: false, + supportedCount: 1, + unsupportedCount: 1, + totalCount: 2, + unsupportedGroups: [{ model: "S19", firmwareVersion: "1.0.0", count: 1 }], + supportedDeviceIdentifiers: ["device-1"], + }); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.ONLINE }, + ], + selectionMode: "subset", + }), + ); + + const coolingModeAction = result.current.popoverActions.find((a) => a.action === settingsActions.coolingMode); + + await act(async () => { + await coolingModeAction?.actionHandler(); + }); + + // Unsupported miners modal should be shown + expect(result.current.unsupportedMinersInfo.visible).toBe(true); + expect(result.current.unsupportedMinersInfo.supportedDeviceIdentifiers).toEqual(["device-1"]); + + // Continue with supported miners only + await act(async () => { + result.current.handleUnsupportedMinersContinue(); + }); + + // Now modal should be shown with filtered count + expect(result.current.showCoolingModeModal).toBe(true); + expect(result.current.coolingModeCount).toBe(1); + + // Confirm with cooling mode + act(() => { + result.current.handleCoolingModeConfirm(CoolingMode.IMMERSION_COOLED); + }); + + // Should have been called with only the supported device + expect(mockSetCoolingMode).toHaveBeenCalled(); + expect(mockStartBatchOperation).toHaveBeenCalledWith({ + batchIdentifier: "batch-cooling-filtered", + action: settingsActions.coolingMode, + deviceIdentifiers: ["device-1"], + }); + }); + }); + + describe("handleConfirmation", () => { + it("should call stopMining API when confirming shutdown action", async () => { + mockStopMining.mockImplementation(({ onSuccess }: any) => { + onSuccess({ batchIdentifier: "batch-shutdown" }); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + // Set current action to shutdown + const shutdownAction = result.current.popoverActions.find((a) => a.action === deviceActions.shutdown); + + await act(async () => { + await shutdownAction?.actionHandler(); + }); + + // Call handleConfirmation + await act(async () => { + await result.current.handleConfirmation(); + }); + + expect(mockStopMining).toHaveBeenCalled(); + expect(mockStartBatchOperation).toHaveBeenCalledWith({ + batchIdentifier: "batch-shutdown", + action: deviceActions.shutdown, + deviceIdentifiers: ["device-1"], + }); + expect(result.current.currentAction).toBeNull(); + }); + + it("should call startMining API when confirming wake up action", async () => { + mockStartMining.mockImplementation(({ onSuccess }: any) => { + onSuccess({ batchIdentifier: "batch-wakeup" }); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.INACTIVE }], + selectionMode: "subset", + }), + ); + + const wakeUpAction = result.current.popoverActions.find((a) => a.action === deviceActions.wakeUp); + + await act(async () => { + await wakeUpAction?.actionHandler(); + }); + + await act(async () => { + await result.current.handleConfirmation(); + }); + + expect(mockStartMining).toHaveBeenCalled(); + expect(mockStartBatchOperation).toHaveBeenCalledWith({ + batchIdentifier: "batch-wakeup", + action: deviceActions.wakeUp, + deviceIdentifiers: ["device-1"], + }); + }); + + it("should call deleteMiners API with explicit device identifiers in subset mode", async () => { + mockDeleteMiners.mockImplementation(({ onSuccess }: any) => { + onSuccess({ deletedCount: 1 }); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + const deleteAction = result.current.popoverActions.find((a) => a.action === deviceActions.unpair); + + act(() => { + deleteAction?.actionHandler(); + }); + + await act(async () => { + await result.current.handleConfirmation(); + }); + + expect(mockStartBatchOperation).toHaveBeenCalledWith( + expect.objectContaining({ + action: deviceActions.unpair, + deviceIdentifiers: ["device-1"], + }), + ); + expect(mockDeleteMiners).toHaveBeenCalled(); + const calledWith = mockDeleteMiners.mock.calls[0][0]; + const selector = calledWith.deleteMinersRequest.deviceSelector; + expect(selector.selectionType.case).toBe("includeDevices"); + expect(selector.selectionType.value.deviceIdentifiers).toEqual(["device-1"]); + expect(mockCompleteBatchOperation).toHaveBeenCalled(); + expect(toaster.updateToast).toHaveBeenCalledWith( + expect.any(Number), + expect.objectContaining({ + message: "Unpaired 1 miner", + status: "success", + }), + ); + }); + + it("should complete batch operation on deleteMiners error", async () => { + mockDeleteMiners.mockImplementation(({ onError }: any) => { + onError("delete failed"); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + const deleteAction = result.current.popoverActions.find((a) => a.action === deviceActions.unpair); + + act(() => { + deleteAction?.actionHandler(); + }); + + await act(async () => { + await result.current.handleConfirmation(); + }); + + expect(mockStartBatchOperation).toHaveBeenCalledWith( + expect.objectContaining({ + action: deviceActions.unpair, + deviceIdentifiers: ["device-1"], + }), + ); + expect(mockCompleteBatchOperation).toHaveBeenCalled(); + }); + + it("should call deleteMiners API with allDevices selector and filter in 'all' mode", async () => { + mockDeleteMiners.mockImplementation(({ onSuccess }: any) => { + onSuccess({ deletedCount: 10 }); + }); + + const activeFilter = createProto(MinerListFilterSchema, { + deviceStatus: [DeviceStatus.ERROR], + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.ONLINE }, + ], + selectionMode: "all", + totalCount: 10, + currentFilter: activeFilter, + }), + ); + + const deleteAction = result.current.popoverActions.find((a) => a.action === deviceActions.unpair); + + act(() => { + deleteAction?.actionHandler(); + }); + + await act(async () => { + await result.current.handleConfirmation(); + }); + + expect(mockStartBatchOperation).toHaveBeenCalledWith( + expect.objectContaining({ + action: deviceActions.unpair, + deviceIdentifiers: ["device-1", "device-2"], + }), + ); + expect(mockDeleteMiners).toHaveBeenCalled(); + const calledWith = mockDeleteMiners.mock.calls[0][0]; + const selector = calledWith.deleteMinersRequest.deviceSelector; + expect(selector.selectionType.case).toBe("allDevices"); + expect(selector.selectionType.value.deviceStatus).toEqual([DeviceStatus.ERROR]); + expect(mockCompleteBatchOperation).toHaveBeenCalled(); + expect(toaster.updateToast).toHaveBeenCalledWith( + expect.any(Number), + expect.objectContaining({ + message: "Unpaired 10 miners", + status: "success", + }), + ); + }); + + it("should send allDevices selector in 'all' mode when no active filter", async () => { + mockDeleteMiners.mockImplementation(({ onSuccess }: any) => { + onSuccess({ deletedCount: 5 }); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.ONLINE }, + ], + selectionMode: "all", + totalCount: 5, + }), + ); + + const deleteAction = result.current.popoverActions.find((a) => a.action === deviceActions.unpair); + + act(() => { + deleteAction?.actionHandler(); + }); + + await act(async () => { + await result.current.handleConfirmation(); + }); + + expect(mockDeleteMiners).toHaveBeenCalled(); + const calledWith = mockDeleteMiners.mock.calls[0][0]; + const selector = calledWith.deleteMinersRequest.deviceSelector; + expect(selector.selectionType.case).toBe("allDevices"); + expect(selector.selectionType.value).toBeDefined(); + }); + + it("should use includeDevices selector in subset mode even with active filter", async () => { + mockDeleteMiners.mockImplementation(({ onSuccess }: any) => { + onSuccess({ deletedCount: 1 }); + }); + + const activeFilter = createProto(MinerListFilterSchema, { + deviceStatus: [DeviceStatus.ERROR], + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + currentFilter: activeFilter, + }), + ); + + const deleteAction = result.current.popoverActions.find((a) => a.action === deviceActions.unpair); + + act(() => { + deleteAction?.actionHandler(); + }); + + await act(async () => { + await result.current.handleConfirmation(); + }); + + expect(mockDeleteMiners).toHaveBeenCalled(); + const calledWith = mockDeleteMiners.mock.calls[0][0]; + const selector = calledWith.deleteMinersRequest.deviceSelector; + expect(selector.selectionType.case).toBe("includeDevices"); + expect(selector.selectionType.value.deviceIdentifiers).toEqual(["device-1"]); + }); + + it("should call reboot API when confirming reboot action", async () => { + mockReboot.mockImplementation(({ onSuccess }: any) => { + onSuccess({ batchIdentifier: "batch-reboot" }); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + const rebootAction = result.current.popoverActions.find((a) => a.action === deviceActions.reboot); + + await act(async () => { + await rebootAction?.actionHandler(); + }); + + await act(async () => { + await result.current.handleConfirmation(); + }); + + expect(mockReboot).toHaveBeenCalled(); + expect(mockStartBatchOperation).toHaveBeenCalledWith({ + batchIdentifier: "batch-reboot", + action: deviceActions.reboot, + deviceIdentifiers: ["device-1"], + }); + }); + }); + + describe("handleCancel", () => { + it("should reset currentAction to null and call onActionComplete", async () => { + const onActionComplete = vi.fn(); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + onActionComplete, + }), + ); + + // Set an action first + const rebootAction = result.current.popoverActions.find((a) => a.action === deviceActions.reboot); + + await act(async () => { + await rebootAction?.actionHandler(); + }); + + expect(result.current.currentAction).toBe(deviceActions.reboot); + + // Cancel + act(() => { + result.current.handleCancel(); + }); + + expect(result.current.currentAction).toBeNull(); + expect(onActionComplete).toHaveBeenCalled(); + }); + }); + + describe("Callbacks", () => { + it("should call onActionStart when confirmation action is triggered", async () => { + const onActionStart = vi.fn(); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + onActionStart, + }), + ); + + const rebootAction = result.current.popoverActions.find((a) => a.action === deviceActions.reboot); + + await act(async () => { + await rebootAction?.actionHandler(); + }); + + expect(onActionStart).toHaveBeenCalled(); + }); + + it("should call onActionComplete when handleCancel is called", () => { + const onActionComplete = vi.fn(); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + onActionComplete, + }), + ); + + act(() => { + result.current.handleCancel(); + }); + + expect(onActionComplete).toHaveBeenCalled(); + }); + }); + + describe("handleMiningPoolSuccess", () => { + it("should start batch operation and push toast", () => { + const batchIdentifier = "batch-pool"; + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + act(() => { + result.current.handleMiningPoolSuccess(batchIdentifier); + }); + + expect(mockStartBatchOperation).toHaveBeenCalledWith({ + batchIdentifier, + action: settingsActions.miningPool, + deviceIdentifiers: ["device-1"], + }); + + expect(toaster.pushToast).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Assigning pools miners", + status: toaster.STATUSES.loading, + longRunning: true, + }), + ); + + expect(result.current.currentAction).toBeNull(); + }); + }); + + describe("handleMiningPoolError", () => { + it("should push error toast and reset current action", () => { + const onActionComplete = vi.fn(); + const errorMessage = "Failed to assign pool"; + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + onActionComplete, + }), + ); + + // Set current action first + const poolAction = result.current.popoverActions.find((a) => a.action === settingsActions.miningPool); + + act(() => { + poolAction?.actionHandler(); + }); + + // Trigger error + act(() => { + result.current.handleMiningPoolError(errorMessage); + }); + + expect(toaster.pushToast).toHaveBeenCalledWith({ + message: errorMessage, + status: toaster.STATUSES.error, + longRunning: true, + }); + + expect(result.current.currentAction).toBeNull(); + expect(onActionComplete).toHaveBeenCalled(); + }); + }); + + describe("Status polling optimization with visible miners", () => { + it("should filter telemetry fetch to only visible miners", () => { + // This test verifies the filtering logic without relying on polling timing + const successDeviceIds = ["device-1", "device-2", "device-3"]; + const visibleMinerIds = new Set(["device-1", "device-3"]); + + // Test the filtering logic that the implementation uses + const visibleSuccessDeviceIds = successDeviceIds.filter((id) => visibleMinerIds.has(id)); + + expect(visibleSuccessDeviceIds).toEqual(["device-1", "device-3"]); + expect(visibleSuccessDeviceIds).not.toContain("device-2"); + }); + }); + + describe("Reboot status completion check", () => { + it("should consider reboot complete when device status is ONLINE", () => { + // Test the status check logic directly - TypeScript knows this is always true, + // but we're testing the runtime behavior for documentation purposes + const deviceStatus: DeviceStatus = DeviceStatus.ONLINE; + // @ts-expect-error - Testing runtime behavior: any non-OFFLINE status completes reboot + const isRebootComplete = deviceStatus !== DeviceStatus.OFFLINE; + + expect(isRebootComplete).toBe(true); + }); + + it("should consider reboot complete when device status is NEEDS_MINING_POOL", () => { + // Test the status check logic directly + const deviceStatus: DeviceStatus = DeviceStatus.NEEDS_MINING_POOL; + // @ts-expect-error - Testing runtime behavior: any non-OFFLINE status completes reboot + const isRebootComplete = deviceStatus !== DeviceStatus.OFFLINE; + + expect(isRebootComplete).toBe(true); + }); + + it("should consider reboot complete when device status is ERROR", () => { + // Test the status check logic directly + const deviceStatus: DeviceStatus = DeviceStatus.ERROR; + // @ts-expect-error - Testing runtime behavior: any non-OFFLINE status completes reboot + const isRebootComplete = deviceStatus !== DeviceStatus.OFFLINE; + + expect(isRebootComplete).toBe(true); + }); + + it("should NOT consider reboot complete when device status is OFFLINE", () => { + // Test the status check logic directly + const deviceStatus = DeviceStatus.OFFLINE; + const isRebootComplete = deviceStatus !== DeviceStatus.OFFLINE; + + expect(isRebootComplete).toBe(false); + }); + }); + + describe("Polling intervals and timeout", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should poll every 3 seconds during status confirmation", async () => { + const successDeviceIds = ["device-1"]; + + mockReboot.mockImplementation(({ onSuccess }: any) => { + onSuccess({ batchIdentifier: "batch-reboot" }); + }); + + mockStreamCommandBatchUpdates.mockImplementation(({ onStreamData }: any) => { + setTimeout(() => { + onStreamData({ + status: { + commandBatchDeviceCount: { + total: BigInt(1), + success: BigInt(1), + failure: BigInt(0), + successDeviceIdentifiers: successDeviceIds, + failureDeviceIdentifiers: [], + }, + }, + }); + }, 100); + // Keep stream open + return new Promise(() => {}) as Promise; + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + // Keep device OFFLINE — previously triggered polling, now batch completes immediately + testMiners["device-1"] = { + deviceIdentifier: "device-1", + deviceStatus: DeviceStatus.OFFLINE, + pairingStatus: PairingStatus.PAIRED, + name: "device-1", + macAddress: "", + serialNumber: "", + model: "", + manufacturer: "", + ipAddress: "", + url: "", + firmwareVersion: "", + powerUsage: [], + temperature: [], + hashrate: [], + efficiency: [], + temperatureStatus: 0, + driverName: "", + } as unknown as MinerStateSnapshot; + + const rebootAction = result.current.popoverActions.find((a) => a.action === deviceActions.reboot); + + await act(async () => { + await rebootAction?.actionHandler(); + }); + + await act(async () => { + await result.current.handleConfirmation(); + }); + + // Wait for stream callback to execute + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + }); + + // Track completion calls before advancing time + const initialCalls = mockCompleteBatchOperation.mock.calls.length; + + // Advance 2.5 seconds - should not poll yet + await act(async () => { + await vi.advanceTimersByTimeAsync(2500); + }); + + expect(mockCompleteBatchOperation.mock.calls.length).toBe(initialCalls); + + // Advance to 3 seconds - should poll once + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + + // Should have polled (but not completed since device still OFFLINE) + expect(mockCompleteBatchOperation.mock.calls.length).toBe(initialCalls); + + // Advance another 3 seconds - should poll again + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + + // Polling happened (still not complete) + expect(mockCompleteBatchOperation.mock.calls.length).toBe(initialCalls); + }); + + it("should timeout after reaching max polls (3 minutes)", () => { + // Test the timeout logic directly + const checkInterval = 3000; // 3 seconds + const maxPolls = 60; // 3 minutes max + const totalTimeoutMs = maxPolls * checkInterval; + + expect(totalTimeoutMs).toBe(180000); // 180 seconds = 3 minutes + expect(maxPolls).toBeGreaterThan(0); + }); + + it("should refetch telemetry every 10 polling cycles (30 seconds)", () => { + // Test the telemetry refetch interval logic directly + const checkInterval = 3000; // 3 seconds per poll + const refetchEveryNPolls = 10; + const refetchIntervalMs = refetchEveryNPolls * checkInterval; + + expect(refetchIntervalMs).toBe(30000); // 30 seconds + + // Test the modulo logic used in implementation + for (let pollCount = 1; pollCount <= 30; pollCount++) { + const shouldRefetch = pollCount % 10 === 0; + if (pollCount === 10 || pollCount === 20 || pollCount === 30) { + expect(shouldRefetch).toBe(true); + } else { + expect(shouldRefetch).toBe(false); + } + } + }); + }); + + describe("Unsupported miners modal flow", () => { + it("should show unsupported miners modal when some miners do not support the action", async () => { + mockCheckCommandCapabilities.mockImplementationOnce(({ onSuccess }: any) => { + onSuccess({ + allSupported: false, + noneSupported: false, + supportedCount: 1, + unsupportedCount: 2, + totalCount: 3, + unsupportedGroups: [{ model: "S19", firmwareVersion: "1.0.0", count: 2 }], + supportedDeviceIdentifiers: ["device-1"], + }); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-3", deviceStatus: DeviceStatus.ONLINE }, + ], + selectionMode: "subset", + }), + ); + + const rebootAction = result.current.popoverActions.find((a) => a.action === deviceActions.reboot); + + await act(async () => { + await rebootAction?.actionHandler(); + }); + + expect(result.current.unsupportedMinersInfo.visible).toBe(true); + expect(result.current.unsupportedMinersInfo.totalUnsupportedCount).toBe(2); + expect(result.current.unsupportedMinersInfo.noneSupported).toBe(false); + expect(result.current.unsupportedMinersInfo.supportedDeviceIdentifiers).toEqual(["device-1"]); + expect(result.current.unsupportedMinersInfo.unsupportedGroups).toHaveLength(1); + }); + + it("should show unsupported miners modal with noneSupported flag when no miners support the action", async () => { + mockCheckCommandCapabilities.mockImplementationOnce(({ onSuccess }: any) => { + onSuccess({ + allSupported: false, + noneSupported: true, + supportedCount: 0, + unsupportedCount: 2, + totalCount: 2, + unsupportedGroups: [{ model: "S19", firmwareVersion: "1.0.0", count: 2 }], + supportedDeviceIdentifiers: [], + }); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.ONLINE }, + ], + selectionMode: "subset", + }), + ); + + const rebootAction = result.current.popoverActions.find((a) => a.action === deviceActions.reboot); + + await act(async () => { + await rebootAction?.actionHandler(); + }); + + expect(result.current.unsupportedMinersInfo.visible).toBe(true); + expect(result.current.unsupportedMinersInfo.noneSupported).toBe(true); + expect(result.current.unsupportedMinersInfo.supportedDeviceIdentifiers).toEqual([]); + expect(result.current.currentAction).toBeNull(); + }); + + it("should not show confirmation dialog when unsupported miners modal is shown", async () => { + mockCheckCommandCapabilities.mockImplementationOnce(({ onSuccess }: any) => { + onSuccess({ + allSupported: false, + noneSupported: false, + supportedCount: 1, + unsupportedCount: 1, + totalCount: 2, + unsupportedGroups: [{ model: "S19", firmwareVersion: "1.0.0", count: 1 }], + supportedDeviceIdentifiers: ["device-1"], + }); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.ONLINE }, + ], + selectionMode: "subset", + }), + ); + + const rebootAction = result.current.popoverActions.find((a) => a.action === deviceActions.reboot); + + await act(async () => { + await rebootAction?.actionHandler(); + }); + + expect(result.current.unsupportedMinersInfo.visible).toBe(true); + expect(result.current.currentAction).toBeNull(); + }); + + it("should execute action with filtered device selector when continuing from unsupported modal", async () => { + mockReboot.mockImplementation(({ onSuccess }: any) => { + onSuccess({ batchIdentifier: "batch-reboot" }); + }); + + mockCheckCommandCapabilities.mockImplementationOnce(({ onSuccess }: any) => { + onSuccess({ + allSupported: false, + noneSupported: false, + supportedCount: 1, + unsupportedCount: 1, + totalCount: 2, + unsupportedGroups: [{ model: "S19", firmwareVersion: "1.0.0", count: 1 }], + supportedDeviceIdentifiers: ["device-1"], + }); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.ONLINE }, + ], + selectionMode: "subset", + }), + ); + + const rebootAction = result.current.popoverActions.find((a) => a.action === deviceActions.reboot); + + await act(async () => { + await rebootAction?.actionHandler(); + }); + + expect(result.current.unsupportedMinersInfo.visible).toBe(true); + expect(result.current.unsupportedMinersInfo.supportedDeviceIdentifiers).toEqual(["device-1"]); + + await act(async () => { + result.current.handleUnsupportedMinersContinue(); + }); + + expect(result.current.unsupportedMinersInfo.visible).toBe(false); + // Verify reboot was called + expect(mockReboot).toHaveBeenCalled(); + // Verify batch operation was started with only the supported device identifier + expect(mockStartBatchOperation).toHaveBeenCalledWith({ + batchIdentifier: "batch-reboot", + action: deviceActions.reboot, + deviceIdentifiers: ["device-1"], + }); + }); + + it("should reset state when dismissing unsupported miners modal", async () => { + const onActionComplete = vi.fn(); + + mockCheckCommandCapabilities.mockImplementationOnce(({ onSuccess }: any) => { + onSuccess({ + allSupported: false, + noneSupported: false, + supportedCount: 1, + unsupportedCount: 1, + totalCount: 2, + unsupportedGroups: [{ model: "S19", firmwareVersion: "1.0.0", count: 1 }], + supportedDeviceIdentifiers: ["device-1"], + }); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.ONLINE }, + ], + selectionMode: "subset", + onActionComplete, + }), + ); + + const rebootAction = result.current.popoverActions.find((a) => a.action === deviceActions.reboot); + + await act(async () => { + await rebootAction?.actionHandler(); + }); + + expect(result.current.unsupportedMinersInfo.visible).toBe(true); + + act(() => { + result.current.handleUnsupportedMinersDismiss(); + }); + + expect(result.current.unsupportedMinersInfo.visible).toBe(false); + expect(result.current.currentAction).toBeNull(); + expect(onActionComplete).toHaveBeenCalled(); + }); + + it("should proceed without modal when all miners support the action", async () => { + mockCheckCommandCapabilities.mockImplementationOnce(({ onSuccess }: any) => { + onSuccess({ + allSupported: true, + noneSupported: false, + supportedCount: 2, + unsupportedCount: 0, + totalCount: 2, + unsupportedGroups: [], + supportedDeviceIdentifiers: ["device-1", "device-2"], + }); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.ONLINE }, + ], + selectionMode: "subset", + }), + ); + + const rebootAction = result.current.popoverActions.find((a) => a.action === deviceActions.reboot); + + await act(async () => { + await rebootAction?.actionHandler(); + }); + + expect(result.current.unsupportedMinersInfo.visible).toBe(false); + expect(result.current.currentAction).toBe(deviceActions.reboot); + }); + + it("should proceed without modal when capability check fails (fail-open)", async () => { + mockCheckCommandCapabilities.mockImplementationOnce(({ onError }: any) => { + onError(new Error("Network error")); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.ONLINE }, + ], + selectionMode: "subset", + }), + ); + + const rebootAction = result.current.popoverActions.find((a) => a.action === deviceActions.reboot); + + await act(async () => { + await rebootAction?.actionHandler(); + }); + + expect(result.current.unsupportedMinersInfo.visible).toBe(false); + expect(result.current.currentAction).toBe(deviceActions.reboot); + }); + }); + + describe("Unpair confirmation contextual subtitles", () => { + const setStoreMiners = ( + miners: Array<{ id: string; driverName: string; deviceStatus: number; pairingStatus: number }>, + ) => { + miners.forEach((m) => { + testMiners[m.id] = { + deviceIdentifier: m.id, + driverName: m.driverName, + deviceStatus: m.deviceStatus, + pairingStatus: m.pairingStatus, + name: m.id, + macAddress: "", + serialNumber: "", + model: "", + manufacturer: "", + ipAddress: "", + url: "", + firmwareVersion: "", + powerUsage: [], + temperature: [], + hashrate: [], + efficiency: [], + temperatureStatus: 0, + } as unknown as MinerStateSnapshot; + }); + }; + + it("should show auth-key-cleared message for single online paired Proto rig", () => { + setStoreMiners([ + { id: "device-1", driverName: "proto", deviceStatus: DeviceStatus.ONLINE, pairingStatus: PairingStatus.PAIRED }, + ]); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + const deleteAction = result.current.popoverActions.find((a) => a.action === deviceActions.unpair); + expect(deleteAction?.confirmation?.subtitle).toBe( + "This miner will be removed from your fleet and its auth key will be cleared.", + ); + }); + + it("should show unreachable warning for single offline Proto rig", () => { + setStoreMiners([ + { + id: "device-1", + driverName: "proto", + deviceStatus: DeviceStatus.OFFLINE, + pairingStatus: PairingStatus.PAIRED, + }, + ]); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.OFFLINE }], + selectionMode: "subset", + }), + ); + + const deleteAction = result.current.popoverActions.find((a) => a.action === deviceActions.unpair); + expect(deleteAction?.confirmation?.subtitle).toBe( + "This miner will be removed from your fleet. It may need to be factory reset before re-pairing.", + ); + }); + + it("should show unreachable warning for single unauthenticated Proto rig", () => { + setStoreMiners([ + { + id: "device-1", + driverName: "proto", + deviceStatus: DeviceStatus.ONLINE, + pairingStatus: PairingStatus.AUTHENTICATION_NEEDED, + }, + ]); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + const deleteAction = result.current.popoverActions.find((a) => a.action === deviceActions.unpair); + expect(deleteAction?.confirmation?.subtitle).toBe( + "This miner will be removed from your fleet. It may need to be factory reset before re-pairing.", + ); + }); + + it("should show telemetry-stop message for single 3rd-party miner", () => { + setStoreMiners([ + { + id: "device-1", + driverName: "bitmain", + deviceStatus: DeviceStatus.ONLINE, + pairingStatus: PairingStatus.PAIRED, + }, + ]); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + const deleteAction = result.current.popoverActions.find((a) => a.action === deviceActions.unpair); + expect(deleteAction?.confirmation?.subtitle).toBe( + "This miner will be removed from your fleet and will stop sending telemetry data.", + ); + }); + + it("should show auth-key-cleared message for multiple online paired Proto rigs", () => { + setStoreMiners([ + { id: "device-1", driverName: "proto", deviceStatus: DeviceStatus.ONLINE, pairingStatus: PairingStatus.PAIRED }, + { id: "device-2", driverName: "proto", deviceStatus: DeviceStatus.ONLINE, pairingStatus: PairingStatus.PAIRED }, + ]); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.ONLINE }, + ], + selectionMode: "subset", + }), + ); + + const deleteAction = result.current.popoverActions.find((a) => a.action === deviceActions.unpair); + expect(deleteAction?.confirmation?.subtitle).toBe( + "These miners will be removed from your fleet and their auth keys will be cleared.", + ); + }); + + it("should show mixed warning when bulk deleting Proto rigs with some unreachable", () => { + setStoreMiners([ + { id: "device-1", driverName: "proto", deviceStatus: DeviceStatus.ONLINE, pairingStatus: PairingStatus.PAIRED }, + { + id: "device-2", + driverName: "proto", + deviceStatus: DeviceStatus.OFFLINE, + pairingStatus: PairingStatus.PAIRED, + }, + { + id: "device-3", + driverName: "bitmain", + deviceStatus: DeviceStatus.ONLINE, + pairingStatus: PairingStatus.PAIRED, + }, + ]); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.OFFLINE }, + { deviceIdentifier: "device-3", deviceStatus: DeviceStatus.ONLINE }, + ], + selectionMode: "subset", + }), + ); + + const deleteAction = result.current.popoverActions.find((a) => a.action === deviceActions.unpair); + expect(deleteAction?.confirmation?.subtitle).toContain("3 miners will be removed"); + expect(deleteAction?.confirmation?.subtitle).toContain("1 Proto miner is unreachable"); + expect(deleteAction?.confirmation?.subtitle).toContain("factory reset"); + }); + + it("should show generic message for 'all' selection mode", () => { + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1" }], + selectionMode: "all", + totalCount: 50, + }), + ); + + const deleteAction = result.current.popoverActions.find((a) => a.action === deviceActions.unpair); + expect(deleteAction?.confirmation?.subtitle).toContain("All 50 miners"); + expect(deleteAction?.confirmation?.subtitle).toContain("removed from your fleet"); + }); + + it("should show 'matching' message for 'all' selection mode with active filter", () => { + const activeFilter = createProto(MinerListFilterSchema, { + deviceStatus: [DeviceStatus.ERROR], + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1" }], + selectionMode: "all", + totalCount: 12, + currentFilter: activeFilter, + }), + ); + + const deleteAction = result.current.popoverActions.find((a) => a.action === deviceActions.unpair); + expect(deleteAction?.confirmation?.subtitle).toContain("12 matching miners"); + expect(deleteAction?.confirmation?.subtitle).toContain("removed from your fleet"); + expect(deleteAction?.confirmation?.subtitle).not.toContain("All"); + }); + + it("should use correct plural for multiple unreachable Proto miners in mixed batch", () => { + setStoreMiners([ + { + id: "device-1", + driverName: "proto", + deviceStatus: DeviceStatus.OFFLINE, + pairingStatus: PairingStatus.PAIRED, + }, + { + id: "device-2", + driverName: "proto", + deviceStatus: DeviceStatus.OFFLINE, + pairingStatus: PairingStatus.PAIRED, + }, + { + id: "device-3", + driverName: "bitmain", + deviceStatus: DeviceStatus.ONLINE, + pairingStatus: PairingStatus.PAIRED, + }, + ]); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.OFFLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.OFFLINE }, + { deviceIdentifier: "device-3", deviceStatus: DeviceStatus.ONLINE }, + ], + selectionMode: "subset", + }), + ); + + const deleteAction = result.current.popoverActions.find((a) => a.action === deviceActions.unpair); + expect(deleteAction?.confirmation?.subtitle).toContain("2 Proto miners are unreachable"); + }); + }); + + describe("Mining pool authentication flow", () => { + it("should show authentication modal when mining pool action handler is called", async () => { + const onActionStart = vi.fn(); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + onActionStart, + }), + ); + + const poolAction = result.current.popoverActions.find((a) => a.action === settingsActions.miningPool); + + await act(async () => { + await poolAction?.actionHandler(); + }); + + expect(result.current.showAuthenticateFleetModal).toBe(true); + expect(result.current.currentAction).toBe(settingsActions.miningPool); + expect(onActionStart).toHaveBeenCalled(); + }); + + it("should show pool selection page after successful authentication", async () => { + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + // Trigger mining pool action + const poolAction = result.current.popoverActions.find((a) => a.action === settingsActions.miningPool); + + await act(async () => { + await poolAction?.actionHandler(); + }); + + expect(result.current.showAuthenticateFleetModal).toBe(true); + + // Authenticate with credentials + await act(async () => { + await result.current.handleFleetAuthenticated("testuser", "testpass"); + }); + + expect(result.current.showAuthenticateFleetModal).toBe(false); + expect(result.current.showPoolSelectionPage).toBe(true); + expect(result.current.fleetCredentials).toEqual({ username: "testuser", password: "testpass" }); + }); + + it("should store pool filtered device IDs when capability check returns partial support", async () => { + mockCheckCommandCapabilities.mockImplementationOnce(({ onSuccess }: any) => { + onSuccess({ + allSupported: false, + noneSupported: false, + supportedCount: 1, + unsupportedCount: 1, + totalCount: 2, + unsupportedGroups: [{ model: "S19", firmwareVersion: "1.0.0", count: 1 }], + supportedDeviceIdentifiers: ["device-1"], + }); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.ONLINE }, + ], + selectionMode: "subset", + }), + ); + + const poolAction = result.current.popoverActions.find((a) => a.action === settingsActions.miningPool); + + await act(async () => { + await poolAction?.actionHandler(); + }); + + // Unsupported miners modal should be shown + expect(result.current.unsupportedMinersInfo.visible).toBe(true); + expect(result.current.unsupportedMinersInfo.supportedDeviceIdentifiers).toEqual(["device-1"]); + + // Continue with supported miners only + await act(async () => { + result.current.handleUnsupportedMinersContinue(); + }); + + // Should show auth modal with filtered device IDs stored + expect(result.current.showAuthenticateFleetModal).toBe(true); + expect(result.current.poolFilteredDeviceIds).toEqual(["device-1"]); + }); + + it("should dismiss pool selection page and reset state when handleCancel is called", async () => { + const onActionComplete = vi.fn(); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + onActionComplete, + }), + ); + + // Trigger mining pool action and authenticate + const poolAction = result.current.popoverActions.find((a) => a.action === settingsActions.miningPool); + + await act(async () => { + await poolAction?.actionHandler(); + }); + + await act(async () => { + await result.current.handleFleetAuthenticated("testuser", "testpass"); + }); + + expect(result.current.showPoolSelectionPage).toBe(true); + + // Cancel/dismiss + act(() => { + result.current.handleCancel(); + }); + + expect(result.current.showPoolSelectionPage).toBe(false); + expect(result.current.currentAction).toBeNull(); + expect(result.current.fleetCredentials).toBeUndefined(); + expect(onActionComplete).toHaveBeenCalled(); + }); + + it("should proceed directly to pool selection when all miners support the action", async () => { + mockCheckCommandCapabilities.mockImplementationOnce(({ onSuccess }: any) => { + onSuccess({ + allSupported: true, + noneSupported: false, + supportedCount: 2, + unsupportedCount: 0, + totalCount: 2, + unsupportedGroups: [], + supportedDeviceIdentifiers: ["device-1", "device-2"], + }); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.ONLINE }, + ], + selectionMode: "subset", + }), + ); + + const poolAction = result.current.popoverActions.find((a) => a.action === settingsActions.miningPool); + + await act(async () => { + await poolAction?.actionHandler(); + }); + + // Should show auth modal directly (no unsupported miners modal) + expect(result.current.unsupportedMinersInfo.visible).toBe(false); + expect(result.current.showAuthenticateFleetModal).toBe(true); + expect(result.current.poolFilteredDeviceIds).toBeUndefined(); + }); + }); + + describe("handlePasswordConfirm - action bar restoration", () => { + const addMinersToStore = ( + _storeInstance: any, + miners: Array<{ deviceIdentifier: string; manufacturer: string; model: string; name?: string }>, + ) => { + miners.forEach((m) => { + testMiners[m.deviceIdentifier] = { + deviceIdentifier: m.deviceIdentifier, + manufacturer: m.manufacturer, + model: m.model, + name: m.name ?? m.model, + driverName: m.manufacturer, + deviceStatus: 0, + pairingStatus: 0, + macAddress: "", + serialNumber: "", + ipAddress: "", + url: "", + firmwareVersion: "", + powerUsage: [], + temperature: [], + hashrate: [], + efficiency: [], + temperatureStatus: 0, + } as unknown as MinerStateSnapshot; + }); + }; + + it("sets group status to failed and keeps ManageSecurityModal open when API call fails", async () => { + const onActionComplete = vi.fn(); + + addMinersToStore(null, [ + { deviceIdentifier: "device-1", manufacturer: "proto", model: "Proto Rig", name: "Proto Rig" }, + ]); + + mockUpdateMinerPassword.mockImplementation(({ onError }: any) => { + onError("Connection failed"); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + onActionComplete, + }), + ); + + const securityAction = result.current.popoverActions.find((a) => a.action === settingsActions.security); + await act(async () => { + await securityAction?.actionHandler(); + }); + await act(async () => { + await result.current.handleFleetAuthenticated("testuser", "testpass"); + }); + + expect(result.current.showManageSecurityModal).toBe(true); + + const group = result.current.minerGroups[0]; + act(() => { + result.current.handleUpdateGroup(group); + }); + expect(result.current.showUpdatePasswordModal).toBe(true); + + act(() => { + result.current.handlePasswordConfirm("oldpass", "newpass"); + }); + + // Modal stays open for retry — onActionComplete not called until modal is closed + expect(onActionComplete).not.toHaveBeenCalled(); + expect(result.current.showManageSecurityModal).toBe(true); + expect(result.current.minerGroups[0].status).toBe("failed"); + }); + + it("does NOT call onActionComplete during batch failure in ManageSecurityModal flow — proto-only selection", async () => { + const onActionComplete = vi.fn(); + + addMinersToStore(null, [ + { deviceIdentifier: "device-1", manufacturer: "proto", model: "Proto Rig", name: "Proto Rig" }, + ]); + + mockUpdateMinerPassword.mockImplementation(({ onSuccess }: any) => { + onSuccess({ batchIdentifier: "batch-security" }); + }); + + mockStreamCommandBatchUpdates.mockImplementation(({ onStreamData }: any) => { + onStreamData({ + status: { + commandBatchDeviceCount: { + total: BigInt(1), + success: BigInt(0), + failure: BigInt(1), + successDeviceIdentifiers: [], + failureDeviceIdentifiers: ["device-1"], + }, + }, + }); + return Promise.resolve(); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + onActionComplete, + }), + ); + + const securityAction = result.current.popoverActions.find((a) => a.action === settingsActions.security); + await act(async () => { + await securityAction?.actionHandler(); + }); + await act(async () => { + await result.current.handleFleetAuthenticated("testuser", "testpass"); + }); + + expect(result.current.showManageSecurityModal).toBe(true); + + const group = result.current.minerGroups[0]; + act(() => { + result.current.handleUpdateGroup(group); + }); + expect(result.current.showUpdatePasswordModal).toBe(true); + + await act(async () => { + result.current.handlePasswordConfirm("oldpass", "newpass"); + }); + + // Modal stays open after batch failure — onActionComplete only called on modal close + expect(onActionComplete).not.toHaveBeenCalled(); + expect(result.current.showManageSecurityModal).toBe(true); + }); + + it("does NOT call onActionComplete during batch completion in ManageSecurityModal flow — modal handles it", async () => { + const onActionComplete = vi.fn(); + + addMinersToStore(null, [ + { deviceIdentifier: "device-1", manufacturer: "proto", model: "Proto Rig", name: "Proto Rig" }, + { deviceIdentifier: "device-2", manufacturer: "bitmain", model: "S19", name: "Antminer S19" }, + ]); + + mockUpdateMinerPassword.mockImplementation(({ onSuccess }: any) => { + onSuccess({ batchIdentifier: "batch-security" }); + }); + + mockStreamCommandBatchUpdates.mockImplementation(({ onStreamData }: any) => { + onStreamData({ + status: { + commandBatchDeviceCount: { + total: BigInt(1), + success: BigInt(0), + failure: BigInt(1), + successDeviceIdentifiers: [], + failureDeviceIdentifiers: ["device-1"], + }, + }, + }); + return Promise.resolve(); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.ONLINE }, + ], + selectionMode: "subset", + onActionComplete, + }), + ); + + const securityAction = result.current.popoverActions.find((a) => a.action === settingsActions.security); + await act(async () => { + await securityAction?.actionHandler(); + }); + await act(async () => { + await result.current.handleFleetAuthenticated("testuser", "testpass"); + }); + + expect(result.current.showManageSecurityModal).toBe(true); + + const protoGroup = result.current.minerGroups.find((g) => g.manufacturer === "proto"); + act(() => { + result.current.handleUpdateGroup(protoGroup!); + }); + expect(result.current.showUpdatePasswordModal).toBe(true); + + await act(async () => { + result.current.handlePasswordConfirm("oldpass", "newpass"); + }); + + // onActionComplete not called yet — ManageSecurityModal is still open + expect(onActionComplete).not.toHaveBeenCalled(); + + // Called only when the modal is closed + act(() => { + result.current.handleSecurityModalClose(); + }); + expect(onActionComplete).toHaveBeenCalledTimes(1); + }); + }); + + describe("Manage security action flow", () => { + const addMinersToStore = ( + _storeInstance: any, + miners: Array<{ deviceIdentifier: string; manufacturer: string; model: string; name?: string }>, + ) => { + miners.forEach((m) => { + testMiners[m.deviceIdentifier] = { + deviceIdentifier: m.deviceIdentifier, + manufacturer: m.manufacturer, + model: m.model, + name: m.name ?? m.model, + driverName: m.manufacturer, + deviceStatus: 0, + pairingStatus: 0, + macAddress: "", + serialNumber: "", + ipAddress: "", + url: "", + firmwareVersion: "", + powerUsage: [], + temperature: [], + hashrate: [], + efficiency: [], + temperatureStatus: 0, + } as unknown as MinerStateSnapshot; + }); + }; + + it("shows auth modal when security action is triggered", async () => { + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + const securityAction = result.current.popoverActions.find((a) => a.action === settingsActions.security); + + await act(async () => { + await securityAction?.actionHandler(); + }); + + expect(result.current.showAuthenticateFleetModal).toBe(true); + expect(result.current.authenticationPurpose).toBe("security"); + }); + + it("shows ManageSecurityModal after auth when all miners are proto rigs", async () => { + addMinersToStore(null, [ + { deviceIdentifier: "device-1", manufacturer: "proto", model: "Proto Rig", name: "Proto Rig" }, + { deviceIdentifier: "device-2", manufacturer: "proto", model: "Proto Rig", name: "Proto Rig 2" }, + ]); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.ONLINE }, + ], + selectionMode: "subset", + }), + ); + + const securityAction = result.current.popoverActions.find((a) => a.action === settingsActions.security); + await act(async () => { + await securityAction?.actionHandler(); + }); + + await act(async () => { + await result.current.handleFleetAuthenticated("testuser", "testpass"); + }); + + expect(result.current.showManageSecurityModal).toBe(true); + expect(result.current.showUpdatePasswordModal).toBe(false); + expect(result.current.minerGroups).toHaveLength(1); + }); + + it("shows ManageSecurityModal after auth when miners include non-proto devices", async () => { + addMinersToStore(null, [ + { deviceIdentifier: "device-1", manufacturer: "proto", model: "Proto Rig", name: "Proto Rig" }, + { deviceIdentifier: "device-2", manufacturer: "bitmain", model: "S19", name: "Antminer S19" }, + ]); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.ONLINE }, + ], + selectionMode: "subset", + }), + ); + + const securityAction = result.current.popoverActions.find((a) => a.action === settingsActions.security); + await act(async () => { + await securityAction?.actionHandler(); + }); + + await act(async () => { + await result.current.handleFleetAuthenticated("testuser", "testpass"); + }); + + expect(result.current.showManageSecurityModal).toBe(true); + expect(result.current.showUpdatePasswordModal).toBe(false); + expect(result.current.minerGroups.length).toBeGreaterThan(0); + }); + + it("handleUpdateGroup opens UpdatePasswordModal for the selected group", async () => { + addMinersToStore(null, [ + { deviceIdentifier: "device-1", manufacturer: "proto", model: "Proto Rig", name: "Proto Rig" }, + { deviceIdentifier: "device-2", manufacturer: "bitmain", model: "S19", name: "Antminer S19" }, + ]); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.ONLINE }, + ], + selectionMode: "subset", + }), + ); + + const securityAction = result.current.popoverActions.find((a) => a.action === settingsActions.security); + await act(async () => { + await securityAction?.actionHandler(); + }); + await act(async () => { + await result.current.handleFleetAuthenticated("testuser", "testpass"); + }); + + const antminerGroup = result.current.minerGroups.find((g) => g.manufacturer === "bitmain"); + expect(antminerGroup).toBeDefined(); + + act(() => { + result.current.handleUpdateGroup(antminerGroup!); + }); + + expect(result.current.showUpdatePasswordModal).toBe(true); + expect(result.current.hasThirdPartyMiners).toBe(true); + }); + + it("handleSecurityModalClose resets all security state and calls onActionComplete", async () => { + const onActionComplete = vi.fn(); + addMinersToStore(null, [ + { deviceIdentifier: "device-1", manufacturer: "bitmain", model: "S19", name: "Antminer S19" }, + ]); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + onActionComplete, + }), + ); + + const securityAction = result.current.popoverActions.find((a) => a.action === settingsActions.security); + await act(async () => { + await securityAction?.actionHandler(); + }); + await act(async () => { + await result.current.handleFleetAuthenticated("testuser", "testpass"); + }); + + expect(result.current.showManageSecurityModal).toBe(true); + + act(() => { + result.current.handleSecurityModalClose(); + }); + + expect(result.current.showManageSecurityModal).toBe(false); + expect(result.current.minerGroups).toHaveLength(0); + expect(result.current.fleetCredentials).toBeUndefined(); + expect(result.current.currentAction).toBeNull(); + expect(onActionComplete).toHaveBeenCalled(); + }); + + it("shows UnsupportedMinersModal after auth when some miners do not support password update", async () => { + mockCheckCommandCapabilities.mockImplementationOnce(({ onSuccess }: any) => { + onSuccess({ + allSupported: false, + noneSupported: false, + supportedCount: 1, + unsupportedCount: 1, + totalCount: 2, + unsupportedGroups: [{ model: "Antminer S19", firmwareVersion: "1.0.0", count: 1 }], + supportedDeviceIdentifiers: ["device-1"], + }); + }); + + addMinersToStore(null, [ + { deviceIdentifier: "device-1", manufacturer: "proto", model: "Proto Rig", name: "Proto Rig" }, + { deviceIdentifier: "device-2", manufacturer: "bitmain", model: "S19", name: "Antminer S19" }, + ]); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.ONLINE }, + ], + selectionMode: "subset", + }), + ); + + const securityAction = result.current.popoverActions.find((a) => a.action === settingsActions.security); + await act(async () => { + await securityAction?.actionHandler(); + }); + + expect(result.current.showAuthenticateFleetModal).toBe(true); + expect(result.current.unsupportedMinersInfo.visible).toBe(false); + + await act(async () => { + await result.current.handleFleetAuthenticated("testuser", "testpass"); + }); + + expect(result.current.unsupportedMinersInfo.visible).toBe(true); + expect(result.current.unsupportedMinersInfo.totalUnsupportedCount).toBe(1); + expect(result.current.unsupportedMinersInfo.noneSupported).toBe(false); + expect(result.current.showManageSecurityModal).toBe(false); + expect(result.current.showUpdatePasswordModal).toBe(false); + }); + }); + + describe("Manage security action flow - select all mode", () => { + const triggerSecurityAndAuthenticate = async (result: any) => { + const securityAction = result.current.popoverActions.find((a: any) => a.action === settingsActions.security); + await act(async () => { + await securityAction?.actionHandler(); + }); + await act(async () => { + await result.current.handleFleetAuthenticated("testuser", "testpass"); + }); + }; + + it("calls getMinerModelGroups to fetch backend groups instead of reading local store", async () => { + mockGetMinerModelGroups.mockResolvedValue([]); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "all", + }), + ); + + await triggerSecurityAndAuthenticate(result); + + expect(mockGetMinerModelGroups).toHaveBeenCalledOnce(); + expect(result.current.showManageSecurityModal).toBe(true); + }); + + it("names Proto Rig groups as manufacturer + model and preserves original manufacturer casing", async () => { + mockGetMinerModelGroups.mockResolvedValue([{ model: "Rig", manufacturer: "Proto", count: 6 }]); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "all", + }), + ); + + await triggerSecurityAndAuthenticate(result); + + const group = result.current.minerGroups[0]; + expect(group.name).toBe("Proto Rig"); + expect(group.manufacturer).toBe("Proto"); + expect(group.count).toBe(6); + }); + + it("names third-party groups by model only, without manufacturer prefix", async () => { + mockGetMinerModelGroups.mockResolvedValue([{ model: "Antminer S19", manufacturer: "Bitmain", count: 10 }]); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "all", + }), + ); + + await triggerSecurityAndAuthenticate(result); + + const group = result.current.minerGroups[0]; + expect(group.name).toBe("Antminer S19"); + expect(group.manufacturer).toBe("Bitmain"); + }); + + it("falls back to capability check path when getMinerModelGroups throws", async () => { + mockGetMinerModelGroups.mockRejectedValue(new Error("Network error")); + + testMiners["device-1"] = { + deviceIdentifier: "device-1", + manufacturer: "proto", + model: "Rig", + name: "Proto Rig", + driverName: "proto", + deviceStatus: 0, + pairingStatus: 0, + macAddress: "", + serialNumber: "", + ipAddress: "", + url: "", + firmwareVersion: "", + powerUsage: [], + temperature: [], + hashrate: [], + efficiency: [], + temperatureStatus: 0, + } as unknown as MinerStateSnapshot; + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "all", + }), + ); + + await triggerSecurityAndAuthenticate(result); + + expect(result.current.showManageSecurityModal).toBe(true); + expect(result.current.minerGroups.length).toBeGreaterThan(0); + }); + + it("uses allDevices selector with model and manufacturer filter in handlePasswordConfirm", async () => { + mockGetMinerModelGroups.mockResolvedValue([{ model: "Rig", manufacturer: "Proto", count: 6 }]); + mockUpdateMinerPassword.mockImplementation(({ onSuccess }: any) => { + onSuccess({ batchIdentifier: "batch-security-all" }); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "all", + }), + ); + + await triggerSecurityAndAuthenticate(result); + + const group = result.current.minerGroups[0]; + await act(async () => { + result.current.handleUpdateGroup(group); + }); + await act(async () => { + result.current.handlePasswordConfirm("oldpass", "newpass"); + }); + + const callArgs = mockUpdateMinerPassword.mock.calls[0][0]; + expect(callArgs.deviceSelector.selectionType.case).toBe("allDevices"); + expect(callArgs.deviceSelector.selectionType.value.models).toEqual(["Rig"]); + expect(callArgs.deviceSelector.selectionType.value.manufacturers).toEqual(["Proto"]); + }); + }); + + describe("Download Logs action", () => { + beforeEach(() => { + // Reset stream mock to its default behavior in case a test overrode it + mockStreamCommandBatchUpdates.mockImplementation((_params: any) => Promise.resolve()); + vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:mock-url"); + vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + vi.spyOn(document.body, "appendChild").mockImplementation((node) => node); + vi.spyOn(document.body, "removeChild").mockImplementation((node) => node); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should include downloadLogs in popoverActions", () => { + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + const actions = result.current.popoverActions.map((a) => a.action); + expect(actions).toContain(deviceActions.downloadLogs); + }); + + it("should call onActionStart to close the menu when triggered", () => { + const onActionStart = vi.fn(); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + onActionStart, + }), + ); + + const downloadLogsAction = result.current.popoverActions.find((a) => a.action === deviceActions.downloadLogs); + act(() => { + downloadLogsAction?.actionHandler(); + }); + + expect(onActionStart).toHaveBeenCalled(); + }); + + it("should show loading toast when download begins", async () => { + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + const downloadLogsAction = result.current.popoverActions.find((a) => a.action === deviceActions.downloadLogs); + await act(async () => { + await downloadLogsAction?.actionHandler(); + }); + + expect(toaster.pushToast).toHaveBeenCalledWith({ + message: "Downloading logs", + status: toaster.STATUSES.loading, + longRunning: true, + }); + }); + + it("should call downloadLogs API with the correct deviceSelector", async () => { + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + const downloadLogsAction = result.current.popoverActions.find((a) => a.action === deviceActions.downloadLogs); + await act(async () => { + await downloadLogsAction?.actionHandler(); + }); + + expect(mockDownloadLogs).toHaveBeenCalled(); + const request = mockDownloadLogs.mock.calls[0][0].downloadLogsRequest; + expect(request.deviceSelector.selectionType.case).toBe("includeDevices"); + expect(request.deviceSelector.selectionType.value.deviceIdentifiers).toEqual(["device-1"]); + }); + + it("should stream batch updates then fetch log bundle after downloadLogs succeeds", async () => { + mockDownloadLogs.mockImplementation(({ onSuccess }: any) => { + onSuccess({ batchIdentifier: "batch-logs-123" }); + }); + mockStreamCommandBatchUpdates.mockImplementation(({ onStreamData }: any) => { + onStreamData({ status: { commandBatchUpdateStatus: 3, commandBatchDeviceCount: { success: 1, failure: 0 } } }); + return Promise.resolve(); + }); + mockGetCommandBatchLogBundle.mockImplementation(({ onSuccess }: any) => { + onSuccess({ chunkData: new Uint8Array([1, 2, 3]), filename: "logs.zip" }); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + // Set up anchor spy after renderHook to avoid intercepting React's internal createElement calls + vi.spyOn(document, "createElement").mockReturnValueOnce({ + href: "", + download: "", + style: {}, + click: vi.fn(), + } as unknown as HTMLElement); + + const downloadLogsAction = result.current.popoverActions.find((a) => a.action === deviceActions.downloadLogs); + await act(async () => { + downloadLogsAction?.actionHandler(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockStreamCommandBatchUpdates).toHaveBeenCalledWith( + expect.objectContaining({ + streamRequest: expect.objectContaining({ batchIdentifier: "batch-logs-123" }), + }), + ); + expect(mockGetCommandBatchLogBundle).toHaveBeenCalledWith( + expect.objectContaining({ + request: expect.objectContaining({ batchIdentifier: "batch-logs-123" }), + }), + ); + }); + + it("should trigger browser file download with the correct filename on success", async () => { + mockDownloadLogs.mockImplementation(({ onSuccess }: any) => { + onSuccess({ batchIdentifier: "batch-logs-123" }); + }); + mockStreamCommandBatchUpdates.mockImplementation(({ onStreamData }: any) => { + onStreamData({ status: { commandBatchUpdateStatus: 3, commandBatchDeviceCount: { success: 1, failure: 0 } } }); + return Promise.resolve(); + }); + mockGetCommandBatchLogBundle.mockImplementation(({ onSuccess }: any) => { + onSuccess({ chunkData: new Uint8Array([1, 2, 3]), filename: "miner-logs.zip" }); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + // Set up anchor spy after renderHook to avoid intercepting React's internal createElement calls + const mockAnchorClick = vi.fn(); + const mockAnchor = { href: "", download: "", style: {}, click: mockAnchorClick }; + vi.spyOn(document, "createElement").mockReturnValueOnce(mockAnchor as unknown as HTMLElement); + + const downloadLogsAction = result.current.popoverActions.find((a) => a.action === deviceActions.downloadLogs); + await act(async () => { + downloadLogsAction?.actionHandler(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockAnchor.download).toBe("miner-logs.zip"); + expect(mockAnchor.href).toBe("blob:mock-url"); + expect(mockAnchorClick).toHaveBeenCalled(); + }); + + it("should show success toast after the file is downloaded", async () => { + mockDownloadLogs.mockImplementation(({ onSuccess }: any) => { + onSuccess({ batchIdentifier: "batch-logs-123" }); + }); + mockStreamCommandBatchUpdates.mockImplementation(({ onStreamData }: any) => { + onStreamData({ status: { commandBatchUpdateStatus: 3, commandBatchDeviceCount: { success: 1, failure: 0 } } }); + return Promise.resolve(); + }); + mockGetCommandBatchLogBundle.mockImplementation(({ onSuccess }: any) => { + onSuccess({ chunkData: new Uint8Array([1, 2, 3]), filename: "logs.zip" }); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + // Set up anchor spy after renderHook to avoid intercepting React's internal createElement calls + vi.spyOn(document, "createElement").mockReturnValueOnce({ + href: "", + download: "", + style: {}, + click: vi.fn(), + } as unknown as HTMLElement); + + const downloadLogsAction = result.current.popoverActions.find((a) => a.action === deviceActions.downloadLogs); + await act(async () => { + downloadLogsAction?.actionHandler(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(toaster.updateToast).toHaveBeenCalledWith( + expect.any(Number), + expect.objectContaining({ + message: "Downloaded logs", + status: toaster.STATUSES.success, + }), + ); + }); + + it("should show error toast when downloadLogs API call fails", async () => { + mockDownloadLogs.mockImplementation(({ onError }: any) => { + onError("Connection failed"); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + const downloadLogsAction = result.current.popoverActions.find((a) => a.action === deviceActions.downloadLogs); + await act(async () => { + await downloadLogsAction?.actionHandler(); + }); + + expect(toaster.updateToast).toHaveBeenCalledWith( + expect.any(Number), + expect.objectContaining({ + message: "Connection failed", + status: toaster.STATUSES.error, + }), + ); + }); + + it("should show error toast when getCommandBatchLogBundle fails after streaming", async () => { + mockDownloadLogs.mockImplementation(({ onSuccess }: any) => { + onSuccess({ batchIdentifier: "batch-logs-123" }); + }); + mockStreamCommandBatchUpdates.mockImplementation(({ onStreamData }: any) => { + onStreamData({ status: { commandBatchUpdateStatus: 3, commandBatchDeviceCount: { success: 1, failure: 0 } } }); + return Promise.resolve(); + }); + mockGetCommandBatchLogBundle.mockImplementation(({ onError }: any) => { + onError("Logs too large to download"); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + const downloadLogsAction = result.current.popoverActions.find((a) => a.action === deviceActions.downloadLogs); + await act(async () => { + downloadLogsAction?.actionHandler(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(toaster.updateToast).toHaveBeenCalledWith( + expect.any(Number), + expect.objectContaining({ + message: "Logs too large to download", + status: toaster.STATUSES.error, + }), + ); + }); + + it("should abort the stream when the batch reports FINISHED status", async () => { + let capturedOnStreamData: ((resp: any) => void) | undefined; + let capturedAbortController: AbortController | undefined; + + mockDownloadLogs.mockImplementation(({ onSuccess }: any) => { + onSuccess({ batchIdentifier: "batch-logs-123" }); + }); + mockStreamCommandBatchUpdates.mockImplementation(({ onStreamData, streamAbortController }: any) => { + capturedOnStreamData = onStreamData; + capturedAbortController = streamAbortController; + return new Promise((resolve) => { + streamAbortController.signal.addEventListener("abort", () => resolve()); + }); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + const downloadLogsAction = result.current.popoverActions.find((a) => a.action === deviceActions.downloadLogs); + await act(async () => { + await downloadLogsAction?.actionHandler(); + }); + + expect(capturedAbortController?.signal.aborted).toBe(false); + + // PROCESSING update should not abort + act(() => { + capturedOnStreamData?.({ + status: { commandBatchUpdateStatus: 2 }, // PROCESSING + }); + }); + expect(capturedAbortController?.signal.aborted).toBe(false); + + // FINISHED update should abort + act(() => { + capturedOnStreamData?.({ + status: { commandBatchUpdateStatus: 3 }, // FINISHED + }); + }); + expect(capturedAbortController?.signal.aborted).toBe(true); + }); + + it("should show error toast and not fetch bundle when all devices fail", async () => { + mockDownloadLogs.mockImplementation(({ onSuccess }: any) => { + onSuccess({ batchIdentifier: "batch-logs-123" }); + }); + mockStreamCommandBatchUpdates.mockImplementation(({ onStreamData }: any) => { + onStreamData({ status: { commandBatchUpdateStatus: 3, commandBatchDeviceCount: { success: 0, failure: 2 } } }); + return Promise.resolve(); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.ONLINE }, + ], + selectionMode: "subset", + }), + ); + + const downloadLogsAction = result.current.popoverActions.find((a) => a.action === deviceActions.downloadLogs); + await act(async () => { + downloadLogsAction?.actionHandler(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(toaster.updateToast).toHaveBeenCalledWith( + expect.any(Number), + expect.objectContaining({ message: "Failed to download logs", status: toaster.STATUSES.error }), + ); + expect(mockGetCommandBatchLogBundle).not.toHaveBeenCalled(); + }); + + it("should show partial failure toast alongside success when some devices fail", async () => { + mockDownloadLogs.mockImplementation(({ onSuccess }: any) => { + onSuccess({ batchIdentifier: "batch-logs-123" }); + }); + mockStreamCommandBatchUpdates.mockImplementation(({ onStreamData }: any) => { + onStreamData({ status: { commandBatchUpdateStatus: 3, commandBatchDeviceCount: { success: 1, failure: 1 } } }); + return Promise.resolve(); + }); + mockGetCommandBatchLogBundle.mockImplementation(({ onSuccess }: any) => { + onSuccess({ chunkData: new Uint8Array([1, 2, 3]), filename: "logs.zip" }); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.ONLINE }, + ], + selectionMode: "subset", + }), + ); + + vi.spyOn(document, "createElement").mockReturnValueOnce({ + href: "", + download: "", + style: {}, + click: vi.fn(), + } as unknown as HTMLElement); + + const downloadLogsAction = result.current.popoverActions.find((a) => a.action === deviceActions.downloadLogs); + await act(async () => { + downloadLogsAction?.actionHandler(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(toaster.updateToast).toHaveBeenCalledWith( + expect.any(Number), + expect.objectContaining({ message: "Downloaded logs", status: toaster.STATUSES.success }), + ); + expect(toaster.pushToast).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Failed to retrieve logs from 1 miner", + status: toaster.STATUSES.error, + }), + ); + }); + + it("should call onActionComplete after the file is downloaded successfully", async () => { + const onActionComplete = vi.fn(); + mockDownloadLogs.mockImplementation(({ onSuccess }: any) => { + onSuccess({ batchIdentifier: "batch-logs-123" }); + }); + mockGetCommandBatchLogBundle.mockImplementation(({ onSuccess }: any) => { + onSuccess({ chunkData: new Uint8Array([1, 2, 3]), filename: "logs.zip" }); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + onActionComplete, + }), + ); + + vi.spyOn(document, "createElement").mockReturnValueOnce({ + href: "", + download: "", + style: {}, + click: vi.fn(), + } as unknown as HTMLElement); + + const downloadLogsAction = result.current.popoverActions.find((a) => a.action === deviceActions.downloadLogs); + await act(async () => { + downloadLogsAction?.actionHandler(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(onActionComplete).toHaveBeenCalled(); + }); + + it("should call onActionComplete when getCommandBatchLogBundle fails", async () => { + const onActionComplete = vi.fn(); + mockDownloadLogs.mockImplementation(({ onSuccess }: any) => { + onSuccess({ batchIdentifier: "batch-logs-123" }); + }); + mockGetCommandBatchLogBundle.mockImplementation(({ onError }: any) => { + onError("Logs too large to download"); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + onActionComplete, + }), + ); + + const downloadLogsAction = result.current.popoverActions.find((a) => a.action === deviceActions.downloadLogs); + await act(async () => { + downloadLogsAction?.actionHandler(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(onActionComplete).toHaveBeenCalled(); + }); + + it("should call onActionComplete when the downloadLogs API call fails", async () => { + const onActionComplete = vi.fn(); + mockDownloadLogs.mockImplementation(({ onError }: any) => { + onError("Connection failed"); + }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + onActionComplete, + }), + ); + + const downloadLogsAction = result.current.popoverActions.find((a) => a.action === deviceActions.downloadLogs); + await act(async () => { + await downloadLogsAction?.actionHandler(); + }); + + expect(onActionComplete).toHaveBeenCalled(); + }); + }); + + describe("Rename miner action", () => { + it("should expose a rename opener that opens the single-miner dialog", () => { + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + expect(result.current.popoverActions.find((a) => a.action === settingsActions.rename)).toBeUndefined(); + + act(() => { + result.current.handleRenameOpen(); + }); + + expect(result.current.showRenameDialog).toBe(true); + expect(result.current.currentAction).toBe(settingsActions.rename); + }); + + it("should call renameSingleMiner with device identifier and name on confirm", async () => { + mockRenameSingleMiner.mockResolvedValue(undefined); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + await act(async () => { + await result.current.handleRenameConfirm("New Name"); + }); + + expect(mockRenameSingleMiner).toHaveBeenCalledWith("device-1", "New Name"); + }); + + it("should show 'Miner renamed' success toast after successful rename", async () => { + mockRenameSingleMiner.mockResolvedValue(undefined); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + await act(async () => { + await result.current.handleRenameConfirm("New Name"); + }); + + expect(toaster.updateToast).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ message: "Miner renamed", status: "success" }), + ); + }); + + it("should show error toast when rename fails", async () => { + mockRenameSingleMiner.mockRejectedValue(new Error("Network error")); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + await act(async () => { + await result.current.handleRenameConfirm("New Name"); + }); + + expect(toaster.updateToast).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ message: "Failed to rename miner", status: "error" }), + ); + }); + + it("should close rename dialog and reset currentAction on confirm", async () => { + mockRenameSingleMiner.mockResolvedValue(undefined); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + act(() => { + result.current.handleRenameOpen(); + }); + + expect(result.current.showRenameDialog).toBe(true); + + await act(async () => { + await result.current.handleRenameConfirm("New Name"); + }); + + expect(result.current.showRenameDialog).toBe(false); + expect(result.current.currentAction).toBeNull(); + }); + + it("should close rename dialog and call onActionComplete on dismiss", () => { + const onActionComplete = vi.fn(); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + onActionComplete, + }), + ); + + act(() => { + result.current.handleRenameOpen(); + }); + + act(() => { + result.current.handleRenameDismiss(); + }); + + expect(result.current.showRenameDialog).toBe(false); + expect(result.current.currentAction).toBeNull(); + expect(onActionComplete).toHaveBeenCalled(); + }); + }); + + describe("Firmware update mixed model guard", () => { + it("uses the canonical settings icon for the firmware action", () => { + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [{ deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }], + selectionMode: "subset", + }), + ); + + const fwAction = result.current.popoverActions.find((a) => a.action === deviceActions.firmwareUpdate); + + expect(fwAction).toEqual(expect.objectContaining({ icon: expect.objectContaining({ type: Settings }) })); + }); + + it("should show error toast and not open modal when selected miners have mixed models", async () => { + testMiners["device-1"] = createProto(MinerStateSnapshotSchema, { deviceIdentifier: "device-1", model: "S19" }); + testMiners["device-2"] = createProto(MinerStateSnapshotSchema, { + deviceIdentifier: "device-2", + model: "Proto Rig", + }); + + const onActionComplete = vi.fn(); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.ONLINE }, + ], + selectionMode: "subset", + onActionComplete, + }), + ); + + const fwAction = result.current.popoverActions.find((a) => a.action === deviceActions.firmwareUpdate); + expect(fwAction).toBeDefined(); + + await act(async () => { + await fwAction!.actionHandler(); + }); + + expect(toaster.pushToast).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("same model"), + status: "error", + }), + ); + expect(result.current.showFirmwareUpdateModal).toBe(false); + expect(onActionComplete).toHaveBeenCalled(); + }); + + it("should open modal when all selected miners have the same model", async () => { + testMiners["device-1"] = createProto(MinerStateSnapshotSchema, { deviceIdentifier: "device-1", model: "S19" }); + testMiners["device-2"] = createProto(MinerStateSnapshotSchema, { deviceIdentifier: "device-2", model: "S19" }); + + const { result } = renderHook(() => + useMinerActions({ + ...batchOpsParams(), + selectedMiners: [ + { deviceIdentifier: "device-1", deviceStatus: DeviceStatus.ONLINE }, + { deviceIdentifier: "device-2", deviceStatus: DeviceStatus.ONLINE }, + ], + selectionMode: "subset", + }), + ); + + const fwAction = result.current.popoverActions.find((a) => a.action === deviceActions.firmwareUpdate); + + await act(async () => { + await fwAction!.actionHandler(); + }); + + expect(result.current.showFirmwareUpdateModal).toBe(true); + }); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/useMinerActions.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/useMinerActions.tsx new file mode 100644 index 000000000..a3d26ba3f --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/useMinerActions.tsx @@ -0,0 +1,1621 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import { create } from "@bufbuild/protobuf"; +import { + deviceActions, + getFailureMessage, + getLoadingMessage, + getSuccessMessage, + groupActions, + loadingMessages, + minersMessage, + performanceActions, + settingsActions, + successMessages, + SupportedAction, +} from "./constants"; +import { useFleetAuthentication } from "./useFleetAuthentication"; +import { useManageSecurityFlow } from "./useManageSecurityFlow"; +import { CoolingMode } from "@/protoFleet/api/generated/common/v1/cooling_pb"; +import { DeviceIdentifierListSchema } from "@/protoFleet/api/generated/common/v1/device_selector_pb"; +import { + DeleteMinersRequestSchema, + type DeleteMinersResponse, + DeviceSelectorSchema, + type MinerListFilter, + MinerListFilterSchema, + type MinerStateSnapshot, + PairingStatus, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { + BlinkLEDRequestSchema, + BlinkLEDResponse, + CommandBatchUpdateStatus_CommandBatchUpdateStatusType, + CommandType, + DeviceSelector, + DownloadLogsRequestSchema, + FirmwareUpdateRequestSchema, + FirmwareUpdateResponse, + GetCommandBatchLogBundleRequestSchema, + PerformanceMode, + RebootRequestSchema, + RebootResponse, + SetCoolingModeResponse, + SetPowerTargetResponse, + StartMiningRequestSchema, + StartMiningResponse, + StopMiningRequestSchema, + StopMiningResponse, + StreamCommandBatchUpdatesRequestSchema, +} from "@/protoFleet/api/generated/minercommand/v1/command_pb"; +import { DeviceStatus } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { useMinerCommand } from "@/protoFleet/api/useMinerCommand"; +import useMinerCoolingMode from "@/protoFleet/api/useMinerCoolingMode"; +import useMinerModelGroups from "@/protoFleet/api/useMinerModelGroups"; +import useRenameMiners from "@/protoFleet/api/useRenameMiners"; +import { + BulkAction, + type UnsupportedMinersInfo, +} from "@/protoFleet/features/fleetManagement/components/BulkActions/types"; +import type { BatchOperationInput } from "@/protoFleet/features/fleetManagement/hooks/useBatchOperations"; +import { createDeviceSelector } from "@/protoFleet/features/fleetManagement/utils/deviceSelector"; +import { + // ArrowLeftCompact, // TODO: Uncomment when Factory Reset is implemented + // Curtail, // TODO: Uncomment when Curtail is implemented + Fan, + Groups, + LEDIndicator, + Lock, + MiningPools, + Play, + Power, + Reboot, + Settings, + Speedometer, + Terminal, + Unpair, +} from "@/shared/assets/icons"; +import { variants } from "@/shared/components/Button"; +import { type SelectionMode } from "@/shared/components/List"; +import { pushToast, removeToast, STATUSES as TOAST_STATUSES, updateToast } from "@/shared/features/toaster"; +import { downloadBlob } from "@/shared/utils/utility"; + +export interface MinerSelection { + deviceIdentifier: string; + deviceStatus?: DeviceStatus; +} + +interface UseMinerActionsParams { + selectedMiners: MinerSelection[]; + selectionMode: SelectionMode; + /** Total count of all miners in fleet (used for "all" mode confirmation dialogs) */ + totalCount?: number; + /** Active UI filter — forwarded as device_filter when unpairing in "all" mode */ + currentFilter?: MinerListFilter; + onActionStart?: () => void; + onActionComplete?: () => void; + /** Start tracking a batch operation (from useBatchOperations) */ + startBatchOperation?: (batch: BatchOperationInput) => void; + /** Complete a batch operation (from useBatchOperations) */ + completeBatchOperation?: (batchIdentifier: string) => void; + /** Remove devices from a batch (from useBatchOperations) */ + removeDevicesFromBatch?: (batchIdentifier: string, deviceIds: string[]) => void; + /** The miners map — used for firmware model checks, unpair subtitle, and security grouping */ + miners?: Record; + /** Replaces store-based refetchMiners — called after unpair completes */ + onRefetchMiners?: () => void; +} + +/** + * Metadata for actions that require capability checking. + * Contains both the description for the unsupported miners modal and the proto CommandType. + * Actions not in this map don't require capability checking (e.g., unpair). + */ +const actionCapabilityMetadata: Partial> = { + [deviceActions.shutdown]: { description: "Sleep mode changes", commandType: CommandType.STOP_MINING }, + [deviceActions.wakeUp]: { description: "Wake-up", commandType: CommandType.START_MINING }, + [deviceActions.reboot]: { description: "Reboot", commandType: CommandType.REBOOT }, + [deviceActions.blinkLEDs]: { description: "LED blinking", commandType: CommandType.BLINK_LED }, + [deviceActions.factoryReset]: { description: "Factory reset", commandType: CommandType.UNSPECIFIED }, + [deviceActions.downloadLogs]: { description: "Log downloads", commandType: CommandType.DOWNLOAD_LOGS }, + [settingsActions.miningPool]: { description: "Pool switching", commandType: CommandType.UPDATE_MINING_POOLS }, + [settingsActions.updateWorkerNames]: { + description: "Worker name updates", + commandType: CommandType.UPDATE_MINING_POOLS, + }, + [settingsActions.coolingMode]: { description: "Cooling mode changes", commandType: CommandType.SET_COOLING_MODE }, + [settingsActions.security]: { description: "Password updates", commandType: CommandType.UPDATE_MINER_PASSWORD }, + [performanceActions.managePower]: { description: "Power mode changes", commandType: CommandType.SET_POWER_TARGET }, + [deviceActions.firmwareUpdate]: { description: "Firmware updates", commandType: CommandType.FIRMWARE_UPDATE }, +}; + +function getUniqueModels( + deviceIds: string[], + miners: Record, +): { models: Set; hasMissing: boolean } { + const models = new Set(); + let hasMissing = false; + for (const id of deviceIds) { + const miner = miners[id]; + const model = miner?.model; + if (model) models.add(model); + else hasMissing = true; + } + return { models, hasMissing }; +} + +/** + * Callback for pending actions that may receive a filtered device selector. + * When called after the unsupported miners modal, receives the filtered selector + * containing only supported miners. + */ +type PendingActionCallback = (filteredSelector?: DeviceSelector, filteredDeviceIdentifiers?: string[]) => void; + +/** + * Internal state for unsupported miners modal, extends UnsupportedMinersInfo with pendingAction. + */ +interface UnsupportedMinersState extends UnsupportedMinersInfo { + pendingAction: PendingActionCallback | null; +} + +const initialUnsupportedMinersState: UnsupportedMinersState = { + visible: false, + unsupportedGroups: [], + totalUnsupportedCount: 0, + noneSupported: false, + pendingAction: null, + supportedDeviceIdentifiers: [], +}; + +const protoDriverName = "proto"; + +/** + * Determines if a Proto rig is reachable for ClearAuthKey. + * A device is reachable if it's not offline and has completed authentication (PAIRED). + */ +const isProtoReachable = (deviceStatus: DeviceStatus, pairingStatus: PairingStatus): boolean => + deviceStatus !== DeviceStatus.OFFLINE && pairingStatus === PairingStatus.PAIRED; + +/** + * Builds a contextual confirmation subtitle for the unpair action based on the + * miner types and statuses in the selection (per RFC Option C). + * + * @param miners - the fleet miners record, passed explicitly for testability + */ +const hasActiveFilter = (filter?: MinerListFilter): boolean => + filter !== undefined && + (filter.deviceStatus.length > 0 || filter.errorComponentTypes.length > 0 || filter.models.length > 0); + +const buildUnpairConfirmationSubtitle = ( + selectedMiners: MinerSelection[], + selectionMode: SelectionMode, + displayCount: number, + miners: Record, + currentFilter?: MinerListFilter, +): string => { + // In "all" mode we may not have full miner data loaded — use a generic message + if (selectionMode === "all") { + if (hasActiveFilter(currentFilter)) { + return `${displayCount} matching ${displayCount === 1 ? "miner" : "miners"} will be removed from your fleet. You can re-discover and pair them again later.`; + } + return `All ${displayCount} miners will be removed from your fleet. You can re-discover and pair them again later.`; + } + + let protoReachableCount = 0; + let protoUnreachableCount = 0; + let thirdPartyCount = 0; + + for (const { deviceIdentifier } of selectedMiners) { + const miner = miners[deviceIdentifier]; + if (!miner) { + thirdPartyCount++; + continue; + } + + if (miner.driverName === protoDriverName) { + if (isProtoReachable(miner.deviceStatus as DeviceStatus, miner.pairingStatus as PairingStatus)) { + protoReachableCount++; + } else { + protoUnreachableCount++; + } + } else { + thirdPartyCount++; + } + } + + const isSingle = displayCount === 1; + + // Single miner + if (isSingle) { + if (protoReachableCount === 1) { + return "This miner will be removed from your fleet and its auth key will be cleared."; + } + if (protoUnreachableCount === 1) { + return "This miner will be removed from your fleet. It may need to be factory reset before re-pairing."; + } + return "This miner will be removed from your fleet and will stop sending telemetry data."; + } + + // All same category + if (thirdPartyCount === 0 && protoUnreachableCount === 0) { + return "These miners will be removed from your fleet and their auth keys will be cleared."; + } + if (thirdPartyCount === 0 && protoReachableCount === 0) { + return "These miners will be removed from your fleet. They may need to be factory reset before re-pairing."; + } + if (protoReachableCount === 0 && protoUnreachableCount === 0) { + return "These miners will be removed from your fleet and will stop sending telemetry data."; + } + + // Mixed — summarize with unreachable Proto warning + const parts: string[] = []; + parts.push(`${displayCount} miners will be removed from your fleet.`); + if (protoUnreachableCount > 0) { + parts.push( + `${protoUnreachableCount} Proto ${protoUnreachableCount === 1 ? "miner is" : "miners are"} unreachable and may need factory reset to re-pair.`, + ); + } + return parts.join(" "); +}; + +const noop = () => {}; + +export const useMinerActions = ({ + selectedMiners, + selectionMode, + totalCount, + currentFilter, + onActionStart, + onActionComplete, + startBatchOperation = noop as (batch: BatchOperationInput) => void, + completeBatchOperation = noop as (batchIdentifier: string) => void, + removeDevicesFromBatch = noop as (batchIdentifier: string, deviceIds: string[]) => void, + miners = {} as Record, + onRefetchMiners, +}: UseMinerActionsParams) => { + const { + startMining, + stopMining, + blinkLED, + deleteMiners, + reboot, + streamCommandBatchUpdates, + setPowerTarget, + setCoolingMode, + checkCommandCapabilities, + updateMinerPassword, + downloadLogs, + firmwareUpdate, + getCommandBatchLogBundle, + } = useMinerCommand(); + + const { fetchCoolingMode } = useMinerCoolingMode(); + const { getMinerModelGroups } = useMinerModelGroups(); + const { renameSingleMiner } = useRenameMiners(); + + const [currentAction, setCurrentAction] = useState(null); + const [showRenameDialog, setShowRenameDialog] = useState(false); + const [showManagePowerModal, setShowManagePowerModal] = useState(false); + const [filteredSelectorForPowerModal, setFilteredSelectorForPowerModal] = useState(); + const [managePowerFilteredDeviceIds, setManagePowerFilteredDeviceIds] = useState(undefined); + const [showCoolingModeModal, setShowCoolingModeModal] = useState(false); + const [coolingModeFilteredSelector, setCoolingModeFilteredSelector] = useState(undefined); + const [coolingModeFilteredDeviceIds, setCoolingModeFilteredDeviceIds] = useState(undefined); + const [currentCoolingMode, setCurrentCoolingMode] = useState(undefined); + const [showAddToGroupModal, setShowAddToGroupModal] = useState(false); + const [showFirmwareUpdateModal, setShowFirmwareUpdateModal] = useState(false); + const [firmwareUpdateFilteredSelector, setFirmwareUpdateFilteredSelector] = useState(); + const [firmwareUpdateFilteredDeviceIds, setFirmwareUpdateFilteredDeviceIds] = useState( + undefined, + ); + const [showPoolSelectionPage, setShowPoolSelectionPage] = useState(false); + const [poolFilteredDeviceIds, setPoolFilteredDeviceIds] = useState(undefined); + const [unsupportedMinersInfo, setUnsupportedMinersInfo] = + useState(initialUnsupportedMinersState); + + const numberOfMiners = useMemo(() => selectedMiners.length, [selectedMiners]); + + // Display count for confirmation dialogs - use totalCount when in "all" mode + const displayCount = useMemo( + () => (selectionMode === "all" && totalCount !== undefined ? totalCount : numberOfMiners), + [selectionMode, totalCount, numberOfMiners], + ); + + // Extract device identifiers for API calls + const deviceIdentifiers = useMemo(() => selectedMiners.map((m) => m.deviceIdentifier), [selectedMiners]); + + // Contextual subtitle for unpair confirmation dialog (per RFC Option C) + const unpairConfirmationSubtitle = useMemo( + () => buildUnpairConfirmationSubtitle(selectedMiners, selectionMode, displayCount, miners, currentFilter), + [selectedMiners, selectionMode, displayCount, miners, currentFilter], + ); + + // Create device selector based on selection mode (undefined when nothing selected) + const deviceSelector = useMemo( + () => (selectionMode === "none" ? undefined : createDeviceSelector(selectionMode, deviceIdentifiers)), + [selectionMode, deviceIdentifiers], + ); + + // Determine device status for power state actions + const deviceStatus = useMemo(() => { + if (selectedMiners.length === 0) return undefined; + + const firstStatus = selectedMiners[0]?.deviceStatus; + const allHaveSameStatus = selectedMiners.every((m) => m.deviceStatus === firstStatus); + + return allHaveSameStatus ? firstStatus : undefined; + }, [selectedMiners]); + + // Check for unsupported miners using server-side capability checking. + // Returns a promise that resolves to true if the modal was shown. + const checkAndShowUnsupportedMinersModal = useCallback( + async (action: SupportedAction, proceedAction: PendingActionCallback): Promise => { + const metadata = actionCapabilityMetadata[action]; + + if (!metadata || metadata.commandType === CommandType.UNSPECIFIED || !deviceSelector) { + return false; + } + + return new Promise((resolve) => { + checkCommandCapabilities({ + deviceSelector, + commandType: metadata.commandType, + onSuccess: (result) => { + if (result.allSupported) { + resolve(false); + return; + } + + setUnsupportedMinersInfo({ + visible: true, + unsupportedGroups: result.unsupportedGroups, + totalUnsupportedCount: result.unsupportedCount, + noneSupported: result.noneSupported, + pendingAction: result.noneSupported ? null : proceedAction, + supportedDeviceIdentifiers: result.supportedDeviceIdentifiers, + }); + + resolve(true); + }, + onError: () => { + // On error, proceed without showing modal (fail-open for capability check) + resolve(false); + }, + }); + }); + }, + [deviceSelector, checkCommandCapabilities], + ); + + // Wraps checkAndShowUnsupportedMinersModal with the common proceed pattern: + // onProceed is called with filtered values when the unsupported miners modal + // was shown and the user clicked Continue, or with undefined values when all + // miners support the action (so callers can use `filteredDeviceIds ?? deviceIdentifiers`). + const withCapabilityCheck = useCallback( + async ( + action: SupportedAction, + onProceed: (filteredSelector?: DeviceSelector, filteredDeviceIds?: string[]) => void, + ): Promise => { + const modalShown = await checkAndShowUnsupportedMinersModal(action, onProceed); + if (!modalShown) { + onProceed(undefined, undefined); + } + }, + [checkAndShowUnsupportedMinersModal], + ); + + // Handle continuing from unsupported miners modal + // Creates a filtered device selector with only supported miners + const handleUnsupportedMinersContinue = useCallback(() => { + const { pendingAction, supportedDeviceIdentifiers } = unsupportedMinersInfo; + const filteredSelector = + supportedDeviceIdentifiers.length > 0 ? createDeviceSelector("subset", supportedDeviceIdentifiers) : undefined; + setUnsupportedMinersInfo(initialUnsupportedMinersState); + pendingAction?.(filteredSelector, supportedDeviceIdentifiers); + }, [unsupportedMinersInfo]); + + // Handle dismissing unsupported miners modal + const handleUnsupportedMinersDismiss = useCallback(() => { + setUnsupportedMinersInfo(initialUnsupportedMinersState); + setCurrentAction(null); + onActionComplete?.(); + }, [onActionComplete]); + + const handleSuccess = useCallback( + ( + action: SupportedAction, + originalToastId: number, + batchIdentifier: string, + onBatchComplete?: (successDeviceIds: string[], failureDeviceIds: string[]) => void, + retryAction?: (failedDeviceIds: string[]) => void, + ) => { + const streamAbortController = new AbortController(); + + let errorToastId: number | null = null; + let successCount = 0; + let totalCount = 0; + let successDeviceIds: string[] = []; + let failureDeviceIds: string[] = []; + // Only true when we've received results for every expected device. Guards + // the Retry action below so a premature stream termination (network/auth + // failure, unmount) cannot offer a retry against a still-in-flight batch. + let streamCompletedNormally = false; + + streamCommandBatchUpdates({ + streamRequest: create(StreamCommandBatchUpdatesRequestSchema, { + batchIdentifier, + }), + onStreamData: (response) => { + totalCount = Number(response.status?.commandBatchDeviceCount?.total || 0); + successCount = Number(response.status?.commandBatchDeviceCount?.success || 0); + const failureCount = Number(response.status?.commandBatchDeviceCount?.failure || 0); + + successDeviceIds = response.status?.commandBatchDeviceCount?.successDeviceIdentifiers || []; + failureDeviceIds = response.status?.commandBatchDeviceCount?.failureDeviceIdentifiers || []; + + if (successCount > 0) { + updateToast(originalToastId, { + message: getSuccessMessage(action, `${successCount} out of ${totalCount} ${minersMessage}`), + status: TOAST_STATUSES.success, + }); + } + + if (failureCount > 0) { + const failureMsg = getFailureMessage(action, `${failureCount} out of ${totalCount} ${minersMessage}`); + if (!errorToastId) { + errorToastId = pushToast({ + message: failureMsg, + status: TOAST_STATUSES.error, + longRunning: true, + }); + } else { + updateToast(errorToastId, { + message: failureMsg, + status: TOAST_STATUSES.error, + }); + } + } + + // Close the stream when we've received results for all devices + // This triggers .finally() to clear loading states immediately + if (successCount + failureCount === totalCount && totalCount > 0) { + streamCompletedNormally = true; + streamAbortController.abort(); + } + }, + streamAbortController: streamAbortController, + }).finally(() => { + if (successCount > 0) { + updateToast(originalToastId, { + message: getSuccessMessage(action, `${successCount} out of ${totalCount} ${minersMessage}`), + status: TOAST_STATUSES.success, + }); + } else { + removeToast(originalToastId); + } + + if (streamCompletedNormally && errorToastId && retryAction && failureDeviceIds.length > 0) { + const capturedToastId = errorToastId; + const capturedFailureIds = [...failureDeviceIds]; + // Guard against rapid double-clicks on the Retry button: the toast + // dismissal and re-render are asynchronous, so a second click can + // fire the onClick before the button unmounts. Without this flag, + // that would dispatch the action's API call twice. + let hasFired = false; + updateToast(capturedToastId, { + actions: [ + { + label: "Retry", + onClick: () => { + if (hasFired) return; + hasFired = true; + removeToast(capturedToastId); + retryAction(capturedFailureIds); + }, + }, + ], + }); + } + + onBatchComplete?.(successDeviceIds, failureDeviceIds); + + // Remove failed devices from batch (revert to their original status) + if (failureDeviceIds.length > 0) { + removeDevicesFromBatch(batchIdentifier, failureDeviceIds); + } + + // Actions that change device status (reboot, shutdown, wake-up, pool, firmware) + // are handled by hasReachedExpectedStatus — keep the batch active so the + // in-progress state stays until the device transitions. Stale cleanup + // (5 min) is the safety net. For actions that don't change status + // (blink LEDs, cooling, security, etc.), complete the batch immediately + // so the transient state clears. + const statusChangingActions = new Set([ + settingsActions.miningPool, + deviceActions.shutdown, + deviceActions.wakeUp, + deviceActions.reboot, + deviceActions.firmwareUpdate, + ]); + if (!statusChangingActions.has(action)) { + completeBatchOperation(batchIdentifier); + } + }); + }, + [streamCommandBatchUpdates, removeDevicesFromBatch, completeBatchOperation], + ); + + const handleError = useCallback((originalToastId: number, error: string) => { + updateToast(originalToastId, { + message: error, + status: TOAST_STATUSES.error, + }); + }, []); + + // Centralizes the retry-on-partial-failure loop so every retry toast carries + // `onClose` and every action wires `handleSuccess` identically. + const executeBulkActionWithRetry = useCallback( + (params: { + action: SupportedAction; + runAction: (args: { + deviceSelector: DeviceSelector; + onSuccess: (batchIdentifier: string) => void; + onError: (error: string) => void; + }) => void; + deviceSelector: DeviceSelector; + deviceIdentifiers: string[]; + loadingMessage: string; + }) => { + const { action, runAction, loadingMessage } = params; + + const pushLoadingToast = () => + pushToast({ + message: loadingMessage, + status: TOAST_STATUSES.loading, + longRunning: true, + onClose: () => onActionComplete?.(), + }); + + const execute = (selector: DeviceSelector, deviceIds: string[], toastId: number) => { + runAction({ + deviceSelector: selector, + onSuccess: (batchIdentifier) => { + startBatchOperation({ + batchIdentifier, + action, + deviceIdentifiers: deviceIds, + }); + handleSuccess(action, toastId, batchIdentifier, undefined, (failedIds) => { + execute(createDeviceSelector("subset", failedIds), failedIds, pushLoadingToast()); + }); + }, + onError: (error) => handleError(toastId, error), + }); + }; + + execute(params.deviceSelector, params.deviceIdentifiers, pushLoadingToast()); + }, + [handleSuccess, handleError, onActionComplete, startBatchOperation], + ); + + const handleMiningPoolSuccess = useCallback( + (batchIdentifier: string) => { + startBatchOperation({ + batchIdentifier: batchIdentifier, + action: settingsActions.miningPool, + deviceIdentifiers: deviceIdentifiers, + }); + + const toastId = pushToast({ + message: `${loadingMessages[settingsActions.miningPool]} ${minersMessage}`, + status: TOAST_STATUSES.loading, + longRunning: true, + onClose: () => onActionComplete?.(), + }); + handleSuccess(settingsActions.miningPool, toastId, batchIdentifier); + setCurrentAction(null); + onActionComplete?.(); + }, + [handleSuccess, onActionComplete, startBatchOperation, deviceIdentifiers], + ); + + const handleMiningPoolError = useCallback( + (error: string) => { + pushToast({ + message: error, + status: TOAST_STATUSES.error, + longRunning: true, + }); + setCurrentAction(null); + onActionComplete?.(); + }, + [onActionComplete], + ); + + const handleManagePowerConfirm = useCallback( + (performanceMode: PerformanceMode) => { + const selectorToUse = filteredSelectorForPowerModal ?? deviceSelector; + const deviceIdsToUse = managePowerFilteredDeviceIds ?? deviceIdentifiers; + if (!selectorToUse) return; + setShowManagePowerModal(false); + setFilteredSelectorForPowerModal(undefined); + setManagePowerFilteredDeviceIds(undefined); + + executeBulkActionWithRetry({ + action: performanceActions.managePower, + deviceSelector: selectorToUse, + deviceIdentifiers: deviceIdsToUse, + loadingMessage: `${loadingMessages[performanceActions.managePower]} ${minersMessage}`, + runAction: ({ deviceSelector: selector, onSuccess, onError }) => + setPowerTarget({ + deviceSelector: selector, + performanceMode, + onSuccess: (value: SetPowerTargetResponse) => onSuccess(value.batchIdentifier), + onError, + }), + }); + + setCurrentAction(null); + }, + [ + filteredSelectorForPowerModal, + managePowerFilteredDeviceIds, + deviceSelector, + setPowerTarget, + executeBulkActionWithRetry, + deviceIdentifiers, + ], + ); + + const handleManagePowerDismiss = useCallback(() => { + setShowManagePowerModal(false); + setFilteredSelectorForPowerModal(undefined); + setManagePowerFilteredDeviceIds(undefined); + setCurrentAction(null); + onActionComplete?.(); + }, [onActionComplete]); + + const handleFirmwareUpdateConfirm = useCallback( + (firmwareFileId: string) => { + const selectorToUse = firmwareUpdateFilteredSelector ?? deviceSelector; + const deviceIdsToUse = firmwareUpdateFilteredDeviceIds ?? deviceIdentifiers; + if (!selectorToUse) return; + setShowFirmwareUpdateModal(false); + setFirmwareUpdateFilteredSelector(undefined); + setFirmwareUpdateFilteredDeviceIds(undefined); + setCurrentAction(null); + + const toastId = pushToast({ + message: `${loadingMessages[deviceActions.firmwareUpdate]} ${minersMessage}`, + status: TOAST_STATUSES.loading, + longRunning: true, + progress: 0, + onClose: () => onActionComplete?.(), + }); + + const firmwareUpdateRequest = create(FirmwareUpdateRequestSchema, { + deviceSelector: selectorToUse, + firmwareFileId, + }); + + firmwareUpdate({ + firmwareUpdateRequest, + onSuccess: (value: FirmwareUpdateResponse) => { + startBatchOperation({ + batchIdentifier: value.batchIdentifier, + action: deviceActions.firmwareUpdate, + deviceIdentifiers: deviceIdsToUse, + }); + + const streamAbortController = new AbortController(); + let errorToastId: number | null = null; + let successCount = 0; + let totalCount = 0; + let failureIds: string[] = []; + let completionHandled = false; + + const handleCompletion = () => { + if (completionHandled) return; + completionHandled = true; + + if (successCount > 0) { + updateToast(toastId, { + message: `${successMessages[deviceActions.firmwareUpdate]} ${successCount} out of ${totalCount} ${minersMessage} — reboot required`, + status: TOAST_STATUSES.success, + progress: undefined, + longRunning: true, + ttl: false, + }); + } else { + removeToast(toastId); + } + + if (failureIds.length > 0) { + removeDevicesFromBatch(value.batchIdentifier, failureIds); + } + + // Don't complete batch — let hasReachedExpectedStatus clear the + // in-progress state once the device reports REBOOT_REQUIRED. + // Stale cleanup handles eventual state cleanup. + onRefetchMiners?.(); + onActionComplete?.(); + }; + + streamCommandBatchUpdates({ + streamRequest: create(StreamCommandBatchUpdatesRequestSchema, { + batchIdentifier: value.batchIdentifier, + }), + streamAbortController, + onStreamData: (response) => { + totalCount = Number(response.status?.commandBatchDeviceCount?.total || 0); + successCount = Number(response.status?.commandBatchDeviceCount?.success || 0); + const failureCount = Number(response.status?.commandBatchDeviceCount?.failure || 0); + failureIds = response.status?.commandBatchDeviceCount?.failureDeviceIdentifiers || []; + // successDeviceIdentifiers no longer needed — optimistic mutation removed + const completed = successCount + failureCount; + const progress = totalCount > 0 ? Math.round((completed / totalCount) * 100) : 0; + + if (successCount > 0) { + updateToast(toastId, { + message: `${successMessages[deviceActions.firmwareUpdate]} ${successCount} out of ${totalCount} ${minersMessage}`, + status: TOAST_STATUSES.success, + progress, + }); + } + + if (failureCount > 0) { + if (!errorToastId) { + errorToastId = pushToast({ + message: `Firmware update failed on ${failureCount} out of ${totalCount} ${minersMessage}`, + status: TOAST_STATUSES.error, + longRunning: true, + }); + } else { + updateToast(errorToastId, { + message: `Firmware update failed on ${failureCount} out of ${totalCount} ${minersMessage}`, + status: TOAST_STATUSES.error, + }); + } + } + + if (completed === totalCount && totalCount > 0) { + handleCompletion(); + streamAbortController.abort(); + } + }, + }).finally(() => { + handleCompletion(); + }); + }, + onError: (error) => { + updateToast(toastId, { + message: `Firmware update failed: ${error}`, + status: TOAST_STATUSES.error, + progress: undefined, + }); + onActionComplete?.(); + }, + }); + }, + [ + firmwareUpdateFilteredSelector, + firmwareUpdateFilteredDeviceIds, + deviceSelector, + firmwareUpdate, + startBatchOperation, + removeDevicesFromBatch, + streamCommandBatchUpdates, + deviceIdentifiers, + onActionComplete, + onRefetchMiners, + ], + ); + + const handleFirmwareUpdateDismiss = useCallback(() => { + setShowFirmwareUpdateModal(false); + setFirmwareUpdateFilteredSelector(undefined); + setFirmwareUpdateFilteredDeviceIds(undefined); + setCurrentAction(null); + onActionComplete?.(); + }, [onActionComplete]); + + const handleCoolingModeConfirm = useCallback( + (coolingMode: CoolingMode) => { + const selectorToUse = coolingModeFilteredSelector ?? deviceSelector; + const deviceIdsToUse = coolingModeFilteredDeviceIds ?? deviceIdentifiers; + + if (!selectorToUse) return; + setShowCoolingModeModal(false); + setCoolingModeFilteredSelector(undefined); + setCoolingModeFilteredDeviceIds(undefined); + + executeBulkActionWithRetry({ + action: settingsActions.coolingMode, + deviceSelector: selectorToUse, + deviceIdentifiers: deviceIdsToUse, + loadingMessage: `${loadingMessages[settingsActions.coolingMode]} ${minersMessage}`, + runAction: ({ deviceSelector: selector, onSuccess, onError }) => + setCoolingMode({ + deviceSelector: selector, + coolingMode, + onSuccess: (value: SetCoolingModeResponse) => onSuccess(value.batchIdentifier), + onError, + }), + }); + + setCurrentAction(null); + }, + [ + coolingModeFilteredSelector, + coolingModeFilteredDeviceIds, + deviceSelector, + setCoolingMode, + executeBulkActionWithRetry, + deviceIdentifiers, + ], + ); + + const handleCoolingModeDismiss = useCallback(() => { + setShowCoolingModeModal(false); + setCoolingModeFilteredSelector(undefined); + setCoolingModeFilteredDeviceIds(undefined); + setCurrentCoolingMode(undefined); + setCurrentAction(null); + onActionComplete?.(); + }, [onActionComplete]); + + const handleRenameConfirm = useCallback( + async (name: string) => { + const deviceIdentifier = selectedMiners[0]?.deviceIdentifier; + if (!deviceIdentifier) return; + + setShowRenameDialog(false); + setCurrentAction(null); + + const id = pushToast({ + message: loadingMessages[settingsActions.rename], + status: TOAST_STATUSES.loading, + longRunning: true, + }); + + try { + await renameSingleMiner(deviceIdentifier, name); + updateToast(id, { message: successMessages[settingsActions.rename], status: TOAST_STATUSES.success }); + onRefetchMiners?.(); + } catch { + updateToast(id, { message: "Failed to rename miner", status: TOAST_STATUSES.error }); + } finally { + onActionComplete?.(); + } + }, + [selectedMiners, renameSingleMiner, onActionComplete, onRefetchMiners], + ); + + const handleRenameDismiss = useCallback(() => { + setShowRenameDialog(false); + setCurrentAction(null); + onActionComplete?.(); + }, [onActionComplete]); + + const handleRenameOpen = useCallback(() => { + setCurrentAction(settingsActions.rename); + setShowRenameDialog(true); + onActionStart?.(); + }, [onActionStart]); + + const handleAddToGroupDismiss = useCallback(() => { + setShowAddToGroupModal(false); + setCurrentAction(null); + onActionComplete?.(); + }, [onActionComplete]); + + // Ref used to wire handleSecurityAuthenticated into the auth hook's onAuthenticated callback + // without creating a circular dependency between the two hooks. + const handleSecurityAuthRef = useRef<((username: string, password: string) => Promise) | null>(null); + + const { + showAuthenticateFleetModal, + authenticationPurpose, + fleetCredentials, + startAuthentication, + handleFleetAuthenticated, + handleAuthDismiss, + resetAuthState, + } = useFleetAuthentication({ + onAuthenticated: useCallback((purpose: "security" | "pool", username: string, password: string) => { + if (purpose === "security") { + void handleSecurityAuthRef.current?.(username, password); + } else { + setShowPoolSelectionPage(true); + } + }, []), + onDismiss: useCallback(() => { + setPoolFilteredDeviceIds(undefined); + setShowPoolSelectionPage(false); + setCurrentAction(null); + onActionComplete?.(); + }, [onActionComplete]), + }); + + const { + showManageSecurityModal, + showUpdatePasswordModal, + hasThirdPartyMiners, + minerGroups, + startManageSecurity, + handleSecurityAuthenticated, + handleUpdateGroup, + handleSecurityModalClose, + handlePasswordConfirm, + handlePasswordDismiss, + } = useManageSecurityFlow({ + deviceIdentifiers, + selectionMode, + getMinerModelGroups, + withCapabilityCheck, + updateMinerPassword, + startBatchOperation, + handleSuccess, + handleError, + onActionComplete, + setCurrentAction, + fleetCredentials, + resetAuthState, + miners, + currentFilter, + }); + + handleSecurityAuthRef.current = handleSecurityAuthenticated; + + const handleConfirmation = useCallback( + async (filteredSelector?: DeviceSelector, filteredDeviceIds?: string[], actionOverride?: SupportedAction) => { + // Use filtered selector/identifiers if provided (from unsupported miners modal), + // otherwise use the default selector/identifiers for all selected miners + const selectorToUse = filteredSelector ?? deviceSelector; + const deviceIdsToUse = filteredDeviceIds ?? deviceIdentifiers; + // Use actionOverride when called from unsupported miners modal (where currentAction is null) + const action = actionOverride ?? currentAction; + + if (action === null || !selectorToUse) return; + + // Handle device action API calls + switch (action) { + case deviceActions.shutdown: { + executeBulkActionWithRetry({ + action: deviceActions.shutdown, + deviceSelector: selectorToUse, + deviceIdentifiers: deviceIdsToUse, + loadingMessage: getLoadingMessage(deviceActions.shutdown, minersMessage), + runAction: ({ deviceSelector: selector, onSuccess, onError }) => + stopMining({ + stopMiningRequest: create(StopMiningRequestSchema, { deviceSelector: selector }), + onSuccess: (value: StopMiningResponse) => onSuccess(value.batchIdentifier), + onError, + }), + }); + break; + } + case deviceActions.wakeUp: { + executeBulkActionWithRetry({ + action: deviceActions.wakeUp, + deviceSelector: selectorToUse, + deviceIdentifiers: deviceIdsToUse, + loadingMessage: getLoadingMessage(deviceActions.wakeUp, minersMessage), + runAction: ({ deviceSelector: selector, onSuccess, onError }) => + startMining({ + startMiningRequest: create(StartMiningRequestSchema, { deviceSelector: selector }), + onSuccess: (value: StartMiningResponse) => onSuccess(value.batchIdentifier), + onError, + }), + }); + break; + } + case deviceActions.unpair: { + // Unpair is not retry-eligible (synchronous deletion, not a streamed + // batch command), so it manages its own toast lifecycle. + const unpairToastId = pushToast({ + message: getLoadingMessage(action, minersMessage), + status: TOAST_STATUSES.loading, + longRunning: true, + onClose: () => onActionComplete?.(), + }); + const unpairBatchId = crypto.randomUUID(); + startBatchOperation({ + batchIdentifier: unpairBatchId, + action: deviceActions.unpair, + deviceIdentifiers: deviceIdsToUse, + }); + + const deleteRequest = create(DeleteMinersRequestSchema, { + deviceSelector: create(DeviceSelectorSchema, { + selectionType: + selectionMode === "all" + ? { case: "allDevices", value: currentFilter ?? create(MinerListFilterSchema) } + : { + case: "includeDevices", + value: create(DeviceIdentifierListSchema, { deviceIdentifiers: deviceIdsToUse }), + }, + }), + }); + deleteMiners({ + deleteMinersRequest: deleteRequest, + onSuccess: (value: DeleteMinersResponse) => { + completeBatchOperation(unpairBatchId); + updateToast(unpairToastId, { + message: `${successMessages[deviceActions.unpair]} ${value.deletedCount} ${value.deletedCount === 1 ? "miner" : "miners"}`, + status: TOAST_STATUSES.success, + }); + onRefetchMiners?.(); + onActionComplete?.(); + }, + onError: (error) => { + completeBatchOperation(unpairBatchId); + handleError(unpairToastId, error); + onActionComplete?.(); + }, + }); + break; + } + case deviceActions.reboot: { + executeBulkActionWithRetry({ + action: deviceActions.reboot, + deviceSelector: selectorToUse, + deviceIdentifiers: deviceIdsToUse, + loadingMessage: getLoadingMessage(deviceActions.reboot, minersMessage), + runAction: ({ deviceSelector: selector, onSuccess, onError }) => + reboot({ + rebootRequest: create(RebootRequestSchema, { deviceSelector: selector }), + onSuccess: (value: RebootResponse) => onSuccess(value.batchIdentifier), + onError, + }), + }); + break; + } + default: + pushToast({ + message: "Unimplemented action", + status: TOAST_STATUSES.error, + }); + } + setCurrentAction(null); + }, + [ + currentAction, + onActionComplete, + deviceSelector, + selectionMode, + startMining, + stopMining, + deleteMiners, + reboot, + handleError, + startBatchOperation, + completeBatchOperation, + deviceIdentifiers, + currentFilter, + onRefetchMiners, + executeBulkActionWithRetry, + ], + ); + + const handleCancel = useCallback(() => { + setCurrentAction(null); + setShowPoolSelectionPage(false); + resetAuthState(); + onActionComplete?.(); + }, [resetAuthState, onActionComplete]); + + const popoverActions = useMemo(() => { + // Device actions handlers + const handleBlinkLEDs = () => { + if (!deviceSelector) return; + setCurrentAction(deviceActions.blinkLEDs); + + executeBulkActionWithRetry({ + action: deviceActions.blinkLEDs, + deviceSelector, + deviceIdentifiers, + loadingMessage: loadingMessages[deviceActions.blinkLEDs], + runAction: ({ deviceSelector: selector, onSuccess, onError }) => + blinkLED({ + blinkLEDRequest: create(BlinkLEDRequestSchema, { deviceSelector: selector }), + onSuccess: (value: BlinkLEDResponse) => onSuccess(value.batchIdentifier), + onError, + }), + }); + }; + + const handleDownloadLogs = async () => { + if (!deviceSelector) return; + onActionStart?.(); + + await withCapabilityCheck(deviceActions.downloadLogs, (filteredSelector) => { + const selectorToUse = filteredSelector ?? deviceSelector; + + const id = pushToast({ + message: loadingMessages[deviceActions.downloadLogs], + status: TOAST_STATUSES.loading, + longRunning: true, + }); + + const request = create(DownloadLogsRequestSchema, { deviceSelector: selectorToUse }); + downloadLogs({ + downloadLogsRequest: request, + onSuccess: ({ batchIdentifier }) => { + const streamAbortController = new AbortController(); + let failureCount = 0; + let successCount = 0; + let allDevicesFailed = false; + let finishedReceived = false; + streamCommandBatchUpdates({ + streamRequest: create(StreamCommandBatchUpdatesRequestSchema, { batchIdentifier }), + streamAbortController, + onStreamData: (response) => { + if ( + response.status?.commandBatchUpdateStatus === + CommandBatchUpdateStatus_CommandBatchUpdateStatusType.FINISHED + ) { + failureCount = Number(response.status.commandBatchDeviceCount?.failure ?? 0); + successCount = Number(response.status.commandBatchDeviceCount?.success ?? 0); + allDevicesFailed = successCount === 0 && failureCount > 0; + finishedReceived = true; + streamAbortController.abort(); + } + }, + }).finally(() => { + if (!finishedReceived) { + updateToast(id, { + message: "Failed to download logs", + status: TOAST_STATUSES.error, + }); + onActionComplete?.(); + return; + } + + if (allDevicesFailed) { + updateToast(id, { + message: "Failed to download logs", + status: TOAST_STATUSES.error, + }); + onActionComplete?.(); + return; + } + + getCommandBatchLogBundle({ + request: create(GetCommandBatchLogBundleRequestSchema, { batchIdentifier }), + onSuccess: ({ chunkData, filename }) => { + const mimeType = filename.endsWith(".csv") ? "text/csv" : "application/zip"; + const blob = new Blob([chunkData as Uint8Array], { type: mimeType }); + downloadBlob(blob, filename); + updateToast(id, { + message: successMessages[deviceActions.downloadLogs], + status: TOAST_STATUSES.success, + }); + if (failureCount > 0) { + pushToast({ + message: `Failed to retrieve logs from ${failureCount} ${failureCount === 1 ? "miner" : "miners"}`, + status: TOAST_STATUSES.error, + longRunning: true, + }); + } + onActionComplete?.(); + }, + onError: (err) => { + updateToast(id, { + message: err || "Failed to download logs", + status: TOAST_STATUSES.error, + }); + onActionComplete?.(); + }, + }); + }); + }, + onError: (err) => { + handleError(id, err); + onActionComplete?.(); + }, + }); + }); + }; + + // TODO: Implement Factory Reset action + // const handleFactoryReset = () => { + // setCurrentAction(deviceActions.factoryReset); + // onActionStart?.(); + // }; + + const handleReboot = async () => { + onActionStart?.(); + // Check for unsupported miners first - only show confirmation dialog if all supported + const modalShown = await checkAndShowUnsupportedMinersModal( + deviceActions.reboot, + (filteredSelector, filteredDeviceIds) => { + // This will be called when user clicks Continue on unsupported miners modal + // The confirmation dialog will not be shown, action executes directly + handleConfirmation(filteredSelector, filteredDeviceIds, deviceActions.reboot); + }, + ); + // Only show confirmation dialog if capability modal was not shown + if (!modalShown) { + setCurrentAction(deviceActions.reboot); + } + }; + + const handleShutDown = async () => { + onActionStart?.(); + const modalShown = await checkAndShowUnsupportedMinersModal( + deviceActions.shutdown, + (filteredSelector, filteredDeviceIds) => { + handleConfirmation(filteredSelector, filteredDeviceIds, deviceActions.shutdown); + }, + ); + if (!modalShown) { + setCurrentAction(deviceActions.shutdown); + } + }; + + const handleWakeUp = async () => { + onActionStart?.(); + const modalShown = await checkAndShowUnsupportedMinersModal( + deviceActions.wakeUp, + (filteredSelector, filteredDeviceIds) => { + handleConfirmation(filteredSelector, filteredDeviceIds, deviceActions.wakeUp); + }, + ); + if (!modalShown) { + setCurrentAction(deviceActions.wakeUp); + } + }; + + const handleUnpair = () => { + setCurrentAction(deviceActions.unpair); + onActionStart?.(); + }; + + // Performance actions handlers + const handleManagePower = async () => { + onActionStart?.(); + await withCapabilityCheck(performanceActions.managePower, (filteredSelector, filteredDeviceIds) => { + setFilteredSelectorForPowerModal(filteredSelector); + setManagePowerFilteredDeviceIds(filteredDeviceIds); + setCurrentAction(performanceActions.managePower); + setShowManagePowerModal(true); + }); + }; + + // TODO: Implement Curtail action + // const handleCurtail = () => { + // setCurrentAction(performanceActions.curtail); + // onActionStart?.(); + // }; + + // Settings actions handlers + const handleMiningPool = async () => { + onActionStart?.(); + await withCapabilityCheck(settingsActions.miningPool, (_filteredSelector, filteredDeviceIds) => { + setPoolFilteredDeviceIds(filteredDeviceIds); + setCurrentAction(settingsActions.miningPool); + startAuthentication("pool"); + }); + }; + + const handleCoolingMode = async () => { + onActionStart?.(); + + // For single miner, fetch current cooling mode for prepopulation + if (selectedMiners.length === 1) { + const mode = await fetchCoolingMode(selectedMiners[0].deviceIdentifier); + setCurrentCoolingMode(mode); + } else { + setCurrentCoolingMode(undefined); + } + + await withCapabilityCheck(settingsActions.coolingMode, (filteredSelector, filteredDeviceIds) => { + setCoolingModeFilteredSelector(filteredSelector); + setCoolingModeFilteredDeviceIds(filteredDeviceIds); + setCurrentAction(settingsActions.coolingMode); + setShowCoolingModeModal(true); + }); + }; + + const handleManageSecurity = () => { + onActionStart?.(); + startManageSecurity(); + startAuthentication("security"); + }; + + const handleAddToGroup = () => { + setCurrentAction(groupActions.addToGroup); + setShowAddToGroupModal(true); + onActionStart?.(); + }; + + const handleFirmwareUpdate = async () => { + onActionStart?.(); + + if (selectionMode === "all") { + pushToast({ + message: "Firmware update requires selecting specific miners to verify model compatibility.", + status: TOAST_STATUSES.error, + }); + onActionComplete?.(); + return; + } + + await withCapabilityCheck(deviceActions.firmwareUpdate, (filteredSelector, filteredDeviceIds) => { + const idsToCheck = filteredDeviceIds ?? deviceIdentifiers; + const { models, hasMissing } = + idsToCheck.length > 0 + ? getUniqueModels(idsToCheck, miners) + : { models: new Set(), hasMissing: false }; + + if (models.size === 0) { + pushToast({ + message: "Unable to verify miner model compatibility. Please select specific miners.", + status: TOAST_STATUSES.error, + }); + onActionComplete?.(); + return; + } + + if (hasMissing) { + pushToast({ + message: "Some selected miners have unknown models. Please deselect them before updating firmware.", + status: TOAST_STATUSES.error, + }); + onActionComplete?.(); + return; + } + + if (models.size > 1) { + pushToast({ + message: "Firmware update requires miners of the same model. Your selection includes multiple models.", + status: TOAST_STATUSES.error, + }); + onActionComplete?.(); + return; + } + + setFirmwareUpdateFilteredSelector(filteredSelector); + setFirmwareUpdateFilteredDeviceIds(filteredDeviceIds); + setCurrentAction(deviceActions.firmwareUpdate); + setShowFirmwareUpdateModal(true); + }); + }; + + const sleepAction: BulkAction = { + action: deviceActions.shutdown, + title: "Sleep", + icon: , + actionHandler: handleShutDown, + requiresConfirmation: true, + confirmation: { + title: `Sleep ${displayCount} ${displayCount === 1 ? "miner" : "miners"}?`, + subtitle: `${displayCount === 1 ? "This miner" : "These miners"} will go to sleep and stop hashing.`, + confirmAction: { + title: "Sleep", + variant: variants.primary, + }, + testId: "shutdown-confirm-button", + }, + }; + + const wakeUpAction: BulkAction = { + action: deviceActions.wakeUp, + title: "Wake up", + icon: , + actionHandler: handleWakeUp, + requiresConfirmation: true, + confirmation: { + title: `Wake up ${displayCount} ${displayCount === 1 ? "miner" : "miners"}?`, + subtitle: `${displayCount === 1 ? "This miner" : "These miners"} will wake up and start hashing.`, + confirmAction: { + title: "Wake up", + variant: variants.primary, + }, + testId: "wake-up-confirm-button", + }, + }; + + // Determine which power state actions to show based on device status + const powerStateActions = + deviceStatus === undefined + ? [sleepAction, wakeUpAction] // Bulk actions: show both + : deviceStatus === DeviceStatus.INACTIVE + ? [wakeUpAction] // Single miner asleep: show wake up only + : [sleepAction]; // Single miner active: show sleep only + + return [ + // Device actions - ordered per design specifications + ...powerStateActions, // Sleep/Wake up at top + { + action: deviceActions.reboot, + title: "Reboot", + icon: , + actionHandler: handleReboot, + requiresConfirmation: true, + confirmation: { + title: `Reboot ${displayCount} ${displayCount === 1 ? "miner" : "miners"}?`, + subtitle: `${displayCount === 1 ? "This miner" : "These miners"} will temporarily go offline but will resume hashing automatically after they reboot.`, + confirmAction: { + title: "Reboot", + variant: variants.primary, + }, + testId: "reboot-confirm-button", + }, + }, + { + action: deviceActions.blinkLEDs, + title: "Blink LEDs", + icon: , + actionHandler: handleBlinkLEDs, + requiresConfirmation: false, + }, + { + action: deviceActions.downloadLogs, + title: "Download logs", + icon: , + actionHandler: handleDownloadLogs, + requiresConfirmation: false, + showGroupDivider: true, + }, + // Performance and settings actions + { + action: performanceActions.managePower, + title: "Manage power", + icon: , + actionHandler: handleManagePower, + requiresConfirmation: false, + }, + { + action: deviceActions.firmwareUpdate, + title: "Update firmware", + icon: , + actionHandler: handleFirmwareUpdate, + requiresConfirmation: false, + }, + // TODO: Implement Curtail action + // { + // action: performanceActions.curtail, + // title: "Curtail", + // icon: , + // actionHandler: handleCurtail, + // requiresConfirmation: true, + // confirmation: { + // title: `Curtail ${numberOfMiners} miners?`, + // subtitle: + // "These miners will reduce power to 0.1 kW and stop hashing.", + // confirmAction: { + // title: "Curtail", + // variant: variants.primary, + // }, + // testId: "curtail-confirm-button", + // }, + // }, + { + action: settingsActions.miningPool, + title: "Edit pool", + icon: , + actionHandler: handleMiningPool, + requiresConfirmation: false, + }, + { + action: settingsActions.coolingMode, + title: "Change cooling mode", + icon: , + actionHandler: handleCoolingMode, + requiresConfirmation: false, + showGroupDivider: true, // End of performance/settings group + }, + { + action: groupActions.addToGroup, + title: "Add to group", + icon: , + actionHandler: handleAddToGroup, + requiresConfirmation: false, + showGroupDivider: true, + }, + // TODO: Implement Add to rack action - when implemented, move showGroupDivider from add-to-group to add-to-rack (last in organization group) + // Security and dangerous actions (same group) + { + action: settingsActions.security, + title: "Manage security", + icon: , + actionHandler: handleManageSecurity, + requiresConfirmation: false, + }, + { + action: deviceActions.unpair, + title: "Unpair", + icon: , + actionHandler: handleUnpair, + requiresConfirmation: true, + confirmation: { + title: `Unpair ${displayCount} ${displayCount === 1 ? "miner" : "miners"}?`, + subtitle: unpairConfirmationSubtitle, + confirmAction: { + title: "Unpair", + variant: variants.secondaryDanger, + }, + testId: "unpair-confirm-button", + }, + }, + ] as BulkAction[]; + }, [ + blinkLED, + downloadLogs, + getCommandBatchLogBundle, + streamCommandBatchUpdates, + handleError, + displayCount, + onActionStart, + onActionComplete, + deviceSelector, + deviceStatus, + withCapabilityCheck, + checkAndShowUnsupportedMinersModal, + handleConfirmation, + deviceIdentifiers, + selectionMode, + selectedMiners, + fetchCoolingMode, + unpairConfirmationSubtitle, + startManageSecurity, + startAuthentication, + miners, + executeBulkActionWithRetry, + ]); + + // Extract public UnsupportedMinersInfo (omit internal pendingAction) + const { pendingAction: _, ...publicUnsupportedMinersInfo } = unsupportedMinersInfo; + + // Count for cooling mode modal - use filtered count if available, otherwise displayCount + const coolingModeCount = coolingModeFilteredDeviceIds?.length ?? displayCount; + + return { + currentAction, + setCurrentAction, + popoverActions, + handleConfirmation, + handleCancel, + numberOfMiners, + displayCount, + handleMiningPoolSuccess, + handleMiningPoolError, + showPoolSelectionPage, + poolFilteredDeviceIds, + fleetCredentials, + showManagePowerModal, + handleManagePowerConfirm, + handleManagePowerDismiss, + showFirmwareUpdateModal, + handleFirmwareUpdateConfirm, + handleFirmwareUpdateDismiss, + showCoolingModeModal, + coolingModeCount, + currentCoolingMode, + handleCoolingModeConfirm, + handleCoolingModeDismiss, + showAuthenticateFleetModal, + authenticationPurpose, + showUpdatePasswordModal, + hasThirdPartyMiners, + handleFleetAuthenticated, + handlePasswordConfirm, + handlePasswordDismiss, + handleAuthDismiss, + withCapabilityCheck, + unsupportedMinersInfo: publicUnsupportedMinersInfo, + handleUnsupportedMinersContinue, + handleUnsupportedMinersDismiss, + showManageSecurityModal, + minerGroups, + handleUpdateGroup, + handleSecurityModalClose, + showRenameDialog, + handleRenameOpen, + handleRenameConfirm, + handleRenameDismiss, + showAddToGroupModal, + handleAddToGroupDismiss, + }; +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/waitForWorkerNameBatchResult.ts b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/waitForWorkerNameBatchResult.ts new file mode 100644 index 000000000..297e50cee --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/waitForWorkerNameBatchResult.ts @@ -0,0 +1,48 @@ +import { create } from "@bufbuild/protobuf"; +import { StreamCommandBatchUpdatesRequestSchema } from "@/protoFleet/api/generated/minercommand/v1/command_pb"; +import { useMinerCommand } from "@/protoFleet/api/useMinerCommand"; + +type StreamCommandBatchUpdates = ReturnType["streamCommandBatchUpdates"]; + +export type WorkerNameBatchResult = { + streamFailed: boolean; + successCount: number; + failedCount: number; + successDeviceIds: string[]; +}; + +export async function waitForWorkerNameBatchResult( + streamCommandBatchUpdates: StreamCommandBatchUpdates, + batchIdentifier: string, +): Promise { + const streamAbortController = new AbortController(); + const batchResult: WorkerNameBatchResult = { + streamFailed: false, + successCount: 0, + failedCount: 0, + successDeviceIds: [], + }; + let totalCount = 0; + + await streamCommandBatchUpdates({ + streamRequest: create(StreamCommandBatchUpdatesRequestSchema, { + batchIdentifier, + }), + streamAbortController, + onStreamData: (streamResponse) => { + totalCount = Number(streamResponse.status?.commandBatchDeviceCount?.total || 0); + batchResult.successCount = Number(streamResponse.status?.commandBatchDeviceCount?.success || 0); + batchResult.failedCount = Number(streamResponse.status?.commandBatchDeviceCount?.failure || 0); + batchResult.successDeviceIds = streamResponse.status?.commandBatchDeviceCount?.successDeviceIdentifiers || []; + + if (batchResult.successCount + batchResult.failedCount === totalCount && totalCount > 0) { + streamAbortController.abort(); + } + }, + onError: () => { + batchResult.streamFailed = true; + }, + }); + + return batchResult; +} diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/ManageColumnsModal.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/ManageColumnsModal.tsx new file mode 100644 index 000000000..02d1393dd --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/ManageColumnsModal.tsx @@ -0,0 +1,165 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + closestCenter, + DndContext, + type DragEndEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { minerColTitles } from "./constants"; +import { + type ConfigurableMinerColumn, + createDefaultMinerTableColumnPreferences, + type MinerTableColumnPreference, + type MinerTableColumnPreferences, + reorderMinerTableColumns, + updateMinerTableColumnVisibility, +} from "./minerTableColumnPreferences"; +import { Dismiss, Grip } from "@/shared/assets/icons"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import Checkbox from "@/shared/components/Checkbox"; +import Modal from "@/shared/components/Modal"; + +type ManageColumnsModalProps = { + preferences: MinerTableColumnPreferences; + onDismiss: () => void; + onSave: (preferences: MinerTableColumnPreferences) => void; +}; + +type SortableColumnRowProps = { + column: MinerTableColumnPreference; + onToggleVisible: (columnId: ConfigurableMinerColumn, visible: boolean) => void; +}; + +const SortableColumnRow = ({ column, onToggleVisible }: SortableColumnRowProps) => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: column.id }); + const title = minerColTitles[column.id]; + + return ( +
+ + + {title} + + +
+ ); +}; + +const ManageColumnsModal = ({ preferences, onDismiss, onSave }: ManageColumnsModalProps) => { + const [draftPreferences, setDraftPreferences] = useState(() => preferences); + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + useEffect(() => { + setDraftPreferences(preferences); + }, [preferences]); + + const handleDragEnd = useCallback((event: DragEndEvent) => { + const { active, over } = event; + + if (!over || active.id === over.id) { + return; + } + + setDraftPreferences((current) => + reorderMinerTableColumns(current, active.id as ConfigurableMinerColumn, over.id as ConfigurableMinerColumn), + ); + }, []); + + const handleToggleVisible = useCallback((columnId: ConfigurableMinerColumn, visible: boolean) => { + setDraftPreferences((current) => updateMinerTableColumnVisibility(current, columnId, visible)); + }, []); + + const handleResetToDefaults = useCallback(() => { + setDraftPreferences(createDefaultMinerTableColumnPreferences()); + }, []); + + const handleSave = useCallback(() => { + onSave(draftPreferences); + }, [draftPreferences, onSave]); + + const columnIds = useMemo(() => draftPreferences.columns.map((column) => column.id), [draftPreferences.columns]); + + return ( + +
+
+
+
+ +
+

Manage columns

+

+ Choose which data to display and rearrange columns to match your workflow. +

+
+ +
+
Column
+ + + +
+ {draftPreferences.columns.map((column) => ( + + ))} +
+
+
+
+
+ + ); +}; + +export default ManageColumnsModal; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerEfficiency.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerEfficiency.tsx new file mode 100644 index 000000000..702c6ed1a --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerEfficiency.tsx @@ -0,0 +1,22 @@ +import MinerMeasurement from "./MinerMeasurement"; +import UnsupportedMetric from "./UnsupportedMetric"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { getMinerMeasurement } from "@/protoFleet/features/fleetManagement/utils/getMinerMeasurement"; + +type MinerEfficiencyProps = { + miner: MinerStateSnapshot; +}; + +const MinerEfficiency = ({ miner }: MinerEfficiencyProps) => { + const efficiency = getMinerMeasurement(miner, (m) => m.efficiency); + + // Check if miner doesn't support efficiency reporting + const efficiencyReported = miner?.capabilities?.telemetry?.efficiencyReported; + if (!efficiencyReported) { + return ; + } + + return ; +}; + +export default MinerEfficiency; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerFirmware.test.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerFirmware.test.tsx new file mode 100644 index 000000000..138e54280 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerFirmware.test.tsx @@ -0,0 +1,64 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import MinerFirmware from "./MinerFirmware"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +function createMockMiner(overrides: Partial = {}): MinerStateSnapshot { + return { + deviceIdentifier: "test-device", + name: "", + macAddress: "", + serialNumber: "", + powerUsage: [], + temperature: [], + hashrate: [], + efficiency: [], + ipAddress: "", + url: "", + deviceStatus: 0, + pairingStatus: 0, + model: "", + manufacturer: "", + temperatureStatus: 0, + firmwareVersion: "", + groupLabels: [], + rackLabel: "", + driverName: "", + workerName: "", + ...overrides, + } as MinerStateSnapshot; +} + +describe("MinerFirmware", () => { + it("renders the firmware version when available", () => { + const miner = createMockMiner({ firmwareVersion: "1.2.3" }); + + render(); + + expect(screen.getByText("1.2.3")).toBeInTheDocument(); + }); + + it("renders empty cell when firmware version is empty string", () => { + const miner = createMockMiner({ firmwareVersion: "" }); + + const { container } = render(); + + expect(container.querySelector("span")?.textContent).toBe(""); + }); + + it("renders date-based version format", () => { + const miner = createMockMiner({ firmwareVersion: "2024.01.15" }); + + render(); + + expect(screen.getByText("2024.01.15")).toBeInTheDocument(); + }); + + it("renders semantic version with pre-release tag", () => { + const miner = createMockMiner({ firmwareVersion: "v1.0.0-beta" }); + + render(); + + expect(screen.getByText("v1.0.0-beta")).toBeInTheDocument(); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerFirmware.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerFirmware.tsx new file mode 100644 index 000000000..c1293ea93 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerFirmware.tsx @@ -0,0 +1,12 @@ +import { INACTIVE_PLACEHOLDER } from "./constants"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +type MinerFirmwareProps = { + miner: MinerStateSnapshot; +}; + +const MinerFirmware = ({ miner }: MinerFirmwareProps) => { + return {miner.firmwareVersion ?? INACTIVE_PLACEHOLDER}; +}; + +export default MinerFirmware; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerGroups.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerGroups.tsx new file mode 100644 index 000000000..299777448 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerGroups.tsx @@ -0,0 +1,90 @@ +import { useCallback, useRef } from "react"; +import { Link } from "react-router-dom"; +import { createPortal } from "react-dom"; +import { type DeviceSet } from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { useFloatingPosition } from "@/shared/hooks/useFloatingPosition"; + +type MinerGroupsProps = { + miner: MinerStateSnapshot; + availableGroups: DeviceSet[]; +}; + +const MinerGroups = ({ miner, availableGroups }: MinerGroupsProps) => { + const groupLabels = miner.groupLabels; + const { triggerRef, floatingStyle, isVisible, show, hide } = useFloatingPosition({ + placement: "bottom-start", + maxHeight: 400, + minWidth: 240, + }); + const closeTimeout = useRef | null>(null); + + const open = useCallback(() => { + if (closeTimeout.current) { + clearTimeout(closeTimeout.current); + closeTimeout.current = null; + } + show(); + }, [show]); + + const closeWithDelay = useCallback(() => { + closeTimeout.current = setTimeout(() => { + hide(); + }, 100); + }, [hide]); + + if (!groupLabels || groupLabels.length === 0) { + return ; + } + + const getGroupLink = (label: string) => { + const groupId = availableGroups.find((g) => g.label === label)?.id; + return groupId ? `/groups/${encodeURIComponent(label)}` : undefined; + }; + + if (groupLabels.length === 1) { + const link = getGroupLink(groupLabels[0]); + return link ? ( + + {groupLabels[0]} + + ) : ( + {groupLabels[0]} + ); + } + + return ( + + {groupLabels.length} groups + {isVisible && + createPortal( +
+
    + {groupLabels.map((label) => { + const link = getGroupLink(label); + return ( +
  • + {link ? ( + + {label} + + ) : ( + {label} + )} +
  • + ); + })} +
+
, + document.body, + )} +
+ ); +}; + +export default MinerGroups; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerHashrate.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerHashrate.tsx new file mode 100644 index 000000000..a916ae329 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerHashrate.tsx @@ -0,0 +1,15 @@ +import MinerMeasurement from "./MinerMeasurement"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { getMinerMeasurement } from "@/protoFleet/features/fleetManagement/utils/getMinerMeasurement"; + +type MinerHashrateProps = { + miner: MinerStateSnapshot; +}; + +const MinerHashrate = ({ miner }: MinerHashrateProps) => { + const hashrate = getMinerMeasurement(miner, (m) => m.hashrate); + + return ; +}; + +export default MinerHashrate; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerIpAddress.test.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerIpAddress.test.tsx new file mode 100644 index 000000000..115eb9985 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerIpAddress.test.tsx @@ -0,0 +1,75 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { INACTIVE_PLACEHOLDER } from "./constants"; +import MinerIpAddress from "./MinerIpAddress"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +function createMockMiner(overrides: Partial = {}): MinerStateSnapshot { + return { + deviceIdentifier: "test-device", + name: "", + macAddress: "", + serialNumber: "", + powerUsage: [], + temperature: [], + hashrate: [], + efficiency: [], + ipAddress: "", + url: "", + deviceStatus: 0, + pairingStatus: 0, + model: "", + manufacturer: "", + temperatureStatus: 0, + firmwareVersion: "", + groupLabels: [], + rackLabel: "", + driverName: "", + workerName: "", + ...overrides, + } as MinerStateSnapshot; +} + +describe("MinerIpAddress", () => { + it("renders placeholder when IP address is not available", () => { + const miner = createMockMiner({ ipAddress: "" }); + + render(); + + expect(screen.getByText(INACTIVE_PLACEHOLDER)).toBeInTheDocument(); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + }); + + it("renders non-clickable IP when there is no URL", () => { + const miner = createMockMiner({ ipAddress: "192.168.1.100", url: "" }); + + render(); + + expect(screen.getByText("192.168.1.100")).toBeInTheDocument(); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + }); + + it("renders a link that opens in new tab for HTTP URLs", () => { + const httpUrl = "http://192.168.1.100"; + const miner = createMockMiner({ ipAddress: "192.168.1.100", url: httpUrl }); + + render(); + + const link = screen.getByRole("link", { name: "192.168.1.100" }); + expect(link).toHaveAttribute("href", httpUrl); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("renders a link that opens in new tab for HTTPS URLs", () => { + const httpsUrl = "https://192.168.1.100"; + const miner = createMockMiner({ ipAddress: "192.168.1.100", url: httpsUrl }); + + render(); + + const link = screen.getByRole("link", { name: "192.168.1.100" }); + expect(link).toHaveAttribute("href", httpsUrl); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerIpAddress.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerIpAddress.tsx new file mode 100644 index 000000000..f94d79ef3 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerIpAddress.tsx @@ -0,0 +1,24 @@ +import { INACTIVE_PLACEHOLDER } from "./constants"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +type MinerIpAddressProps = { + miner: MinerStateSnapshot; +}; + +const MinerIpAddress = ({ miner }: MinerIpAddressProps) => { + if (!miner.ipAddress) { + return {INACTIVE_PLACEHOLDER}; + } + + if (!miner.url) { + return {miner.ipAddress}; + } + + return ( + + {miner.ipAddress} + + ); +}; + +export default MinerIpAddress; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerIssues.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerIssues.tsx new file mode 100644 index 000000000..2fe6c663e --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerIssues.tsx @@ -0,0 +1,131 @@ +import { ReactNode, useMemo } from "react"; +import { ComponentType as ErrorComponentType, type ErrorMessage } from "@/protoFleet/api/generated/errors/v1/errors_pb"; +import { DeviceStatus, PairingStatus } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { transformFleetErrorsToShared } from "@/protoFleet/components/StatusModal/utils"; +import { getComponentIcon } from "@/protoFleet/features/fleetManagement/components/MinerList/utils"; +import { Alert } from "@/shared/assets/icons"; +import SkeletonBar from "@/shared/components/SkeletonBar"; +import { useMinerIssues } from "@/shared/hooks/useStatusSummary"; + +type MinerIssuesProps = { + miner: MinerStateSnapshot; + errors: ErrorMessage[]; + errorsLoaded: boolean; + onClick?: () => void; +}; + +// Map from shared error keys to ErrorComponentType +const componentTypeMap: Record = { + hashboard: ErrorComponentType.HASH_BOARD, + psu: ErrorComponentType.PSU, + fan: ErrorComponentType.FAN, + controlBoard: ErrorComponentType.CONTROL_BOARD, +}; + +/** Group errors by component type (same logic as useGroupedErrors but pure) */ +function groupErrors(errors: ErrorMessage[]) { + const grouped = { + hashboard: [] as ErrorMessage[], + psu: [] as ErrorMessage[], + fan: [] as ErrorMessage[], + controlBoard: [] as ErrorMessage[], + other: [] as ErrorMessage[], + }; + errors.forEach((error) => { + switch (error.componentType) { + case ErrorComponentType.HASH_BOARD: + grouped.hashboard.push(error); + break; + case ErrorComponentType.PSU: + grouped.psu.push(error); + break; + case ErrorComponentType.FAN: + grouped.fan.push(error); + break; + case ErrorComponentType.CONTROL_BOARD: + grouped.controlBoard.push(error); + break; + default: + grouped.other.push(error); + break; + } + }); + return grouped; +} + +const MinerIssues = ({ miner, errors, errorsLoaded, onClick }: MinerIssuesProps) => { + const deviceStatus = miner.deviceStatus; + + // Group errors by component type + const groupedErrors = useMemo(() => groupErrors(errors), [errors]); + + // Compute issue flags + const needsAuthentication = miner.pairingStatus === PairingStatus.AUTHENTICATION_NEEDED; + const needsMiningPool = deviceStatus === DeviceStatus.NEEDS_MINING_POOL; + const isUpdating = deviceStatus === DeviceStatus.UPDATING; + const isRebootRequired = deviceStatus === DeviceStatus.REBOOT_REQUIRED; + + // Transform errors to shared format using existing utility + const sharedErrors = useMemo(() => transformFleetErrorsToShared(groupedErrors), [groupedErrors]); + + // Compute issues summary (authentication, pool, firmware status, and hardware errors) + const { summary, hasIssues } = useMinerIssues( + needsAuthentication, + needsMiningPool, + sharedErrors, + isUpdating, + isRebootRequired, + ); + + // Determine icon to show based on issue type + // Note: Auth and pool issues don't have icons (per Figma design) + const icon = useMemo((): ReactNode | null => { + // Auth and pool issues don't get icons + if (needsAuthentication || needsMiningPool) { + return null; + } + + // Derive component types from sharedErrors + const componentTypesWithErrors = Object.entries(sharedErrors) + .filter(([, errors]) => errors.length > 0) + .map(([key]) => componentTypeMap[key]) + .filter((type): type is ErrorComponentType => type !== undefined); + + if (componentTypesWithErrors.length === 0) return null; + if (componentTypesWithErrors.length === 1) { + return getComponentIcon(componentTypesWithErrors[0]); + } + return ; + }, [needsAuthentication, needsMiningPool, sharedErrors]); + + // While errors haven't loaded, show shimmer for devices that could have issues + if (!errorsLoaded && !needsAuthentication && !needsMiningPool && !isUpdating && !isRebootRequired) { + return ; + } + + // Show empty state if no issues + if (!hasIssues) { + return null; + } + + // Issues should always be clickable (even for disabled rows) + const isClickable = !!onClick; + + const content = ( + <> + {icon} + {summary} + + ); + + return isClickable ? ( + + ) : ( +
{content}
+ ); +}; + +export default MinerIssues; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerIssuesCell.test.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerIssuesCell.test.tsx new file mode 100644 index 000000000..6460c500c --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerIssuesCell.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import MinerIssuesCell from "./MinerIssuesCell"; +import type { DeviceListItem } from "./types"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +vi.mock("./MinerIssues", () => ({ + default: ({ onClick }: { onClick: () => void }) => ( + + ), +})); + +function createMockDevice(overrides: Partial = {}): DeviceListItem { + return { + deviceIdentifier: "test-device-id", + miner: { + deviceIdentifier: "test-device-id", + name: "", + macAddress: "", + serialNumber: "", + powerUsage: [], + temperature: [], + hashrate: [], + efficiency: [], + ipAddress: "", + url: "", + deviceStatus: 0, + pairingStatus: 0, + model: "", + manufacturer: "", + temperatureStatus: 0, + firmwareVersion: "", + groupLabels: [], + rackLabel: "", + driverName: "", + workerName: "", + } as unknown as MinerStateSnapshot, + errors: [], + activeBatches: [], + ...overrides, + }; +} + +describe("MinerIssuesCell", () => { + it("calls onOpenStatusFlow when issues are clicked", async () => { + const user = userEvent.setup(); + const onOpenStatusFlow = vi.fn(); + + render(); + + await user.click(screen.getByTestId("miner-issues")); + + expect(onOpenStatusFlow).toHaveBeenCalledTimes(1); + expect(onOpenStatusFlow).toHaveBeenCalledWith("test-device-id"); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerIssuesCell.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerIssuesCell.tsx new file mode 100644 index 000000000..96b44b6cb --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerIssuesCell.tsx @@ -0,0 +1,21 @@ +import MinerIssues from "./MinerIssues"; +import type { DeviceListItem } from "./types"; + +type MinerIssuesCellProps = { + device: DeviceListItem; + errorsLoaded: boolean; + onOpenStatusFlow: (deviceIdentifier: string) => void; +}; + +const MinerIssuesCell = ({ device, errorsLoaded, onOpenStatusFlow }: MinerIssuesCellProps) => { + return ( + onOpenStatusFlow(device.deviceIdentifier)} + /> + ); +}; + +export default MinerIssuesCell; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerList.modalFlow.test.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerList.modalFlow.test.tsx new file mode 100644 index 000000000..e4f9684ff --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerList.modalFlow.test.tsx @@ -0,0 +1,169 @@ +import { MemoryRouter } from "react-router-dom"; +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import MinerList from "./MinerList"; +import { PairingStatus } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { DeviceStatus } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; + +let minersById: Record = {}; + +vi.mock("@/protoFleet/store", () => ({ + useUsername: () => "", +})); + +vi.mock("./minerColConfig", () => ({ + default: ({ onOpenStatusFlow }: { onOpenStatusFlow: (deviceIdentifier: string) => void }) => ({ + status: { + width: "min-w-48", + component: (device: { deviceIdentifier: string }) => ( + + ), + }, + }), +})); + +vi.mock("@/shared/components/List", () => ({ + default: ({ items, colConfig }: any) => ( +
{items?.[0] ? colConfig.status?.component?.(items[0], []) :
}
+ ), +})); + +vi.mock("@/protoFleet/features/auth/components/AuthenticateMiners", () => ({ + AuthenticateMiners: ({ open, onClose }: { open?: boolean; onClose: () => void }) => + open ? ( +
+ +
+ ) : null, +})); + +vi.mock("@/protoFleet/features/auth/components/AuthenticateFleetModal", () => ({ + default: ({ + open, + onAuthenticated, + onDismiss, + }: { + open?: boolean; + onAuthenticated: (username: string, password: string) => void; + onDismiss: () => void; + }) => + open ? ( +
+ + +
+ ) : null, +})); + +vi.mock("@/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage", () => ({ + default: ({ open, onDismiss }: { open?: boolean; onDismiss: () => void }) => + open ? ( +
+ +
+ ) : null, +})); + +vi.mock("@/protoFleet/components/StatusModal", () => ({ + ProtoFleetStatusModal: ({ open, onClose }: { open?: boolean; onClose: () => void }) => + open ? ( +
+ +
+ ) : null, +})); + +vi.mock("@/shared/hooks/useWindowDimensions", () => ({ + useWindowDimensions: () => ({ isPhone: false }), +})); + +vi.mock("@/shared/hooks/useReactiveLocalStorage", () => ({ + useReactiveLocalStorage: () => [false], +})); + +const renderMinerList = () => + render( + + []} + totalMiners={1} + onAddMiners={vi.fn()} + /> + , + ); + +describe("MinerList modal flow orchestration", () => { + beforeEach(() => { + minersById = { + "miner-1": { + pairingStatus: PairingStatus.PAIRED, + deviceStatus: DeviceStatus.ONLINE, + }, + }; + }); + + it("opens AuthenticateMiners for auth-needed miners", async () => { + const user = userEvent.setup(); + minersById["miner-1"] = { + pairingStatus: PairingStatus.AUTHENTICATION_NEEDED, + deviceStatus: DeviceStatus.ONLINE, + }; + + renderMinerList(); + await user.click(screen.getByTestId("open-status-flow")); + + expect(screen.getByTestId("authenticate-miners")).toBeInTheDocument(); + }); + + it("opens fleet auth then pool selection for needs-mining-pool miners and resets on close", async () => { + const user = userEvent.setup(); + minersById["miner-1"] = { + pairingStatus: PairingStatus.PAIRED, + deviceStatus: DeviceStatus.NEEDS_MINING_POOL, + }; + + renderMinerList(); + await user.click(screen.getByTestId("open-status-flow")); + expect(screen.getByTestId("authenticate-fleet-modal")).toBeInTheDocument(); + + await user.click(screen.getByTestId("authenticate-fleet-success")); + expect(screen.getByTestId("pool-selection-page")).toBeInTheDocument(); + + await user.click(screen.getByTestId("pool-selection-close")); + expect(screen.queryByTestId("authenticate-fleet-modal")).not.toBeInTheDocument(); + expect(screen.queryByTestId("pool-selection-page")).not.toBeInTheDocument(); + }); + + it("opens status modal for non-auth, non-pool miners and closes cleanly", async () => { + const user = userEvent.setup(); + minersById["miner-1"] = { + pairingStatus: PairingStatus.PAIRED, + deviceStatus: DeviceStatus.ONLINE, + }; + + renderMinerList(); + await user.click(screen.getByTestId("open-status-flow")); + expect(screen.getByTestId("status-modal")).toBeInTheDocument(); + + await user.click(screen.getByTestId("status-modal-close")); + expect(screen.queryByTestId("status-modal")).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerList.test.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerList.test.tsx new file mode 100644 index 000000000..9bff07128 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerList.test.tsx @@ -0,0 +1,1245 @@ +import { useLayoutEffect, useRef } from "react"; +import { BrowserRouter, MemoryRouter, useLocation } from "react-router-dom"; +import { act, render, screen, waitFor, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import userEvent from "@testing-library/user-event"; + +import MinerList from "./MinerList"; +import { getMinerTableColumnPreferencesStorageKey } from "./minerTableColumnPreferences"; +import useMinerTableColumnPreferences from "./useMinerTableColumnPreferences"; +import { + type MinerStateSnapshot, + MinerStateSnapshotSchema, + PairingStatus, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { DeviceStatus } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { useFleetStore } from "@/protoFleet/store"; + +const { mockMinerListActionBar } = vi.hoisted(() => ({ + mockMinerListActionBar: vi.fn( + ({ + selectedMiners, + selectionMode, + totalCount, + onSelectAll, + onSelectNone, + }: { + selectedMiners: string[]; + selectionMode: string; + totalCount?: number; + onSelectAll?: () => void; + onSelectNone?: () => void; + }) => { + if (selectionMode === "none" && selectedMiners.length === 0) { + return null; + } + + return ( +
+ {selectionMode} + {selectedMiners.join(",")} + + {selectionMode === "all" ? (totalCount ?? selectedMiners.length) : selectedMiners.length} + + {onSelectAll ? ( + + ) : null} + {onSelectNone ? ( + + ) : null} +
+ ); + }, + ), +})); + +vi.mock("./MinerListActionBar", () => ({ + default: mockMinerListActionBar, +})); + +// useMinerActions (used by SingleMinerActionsMenu/MinerActionsMenu in column +// config) imports batch operation hooks from the store that were removed during +// the fleet slice refactor. Mock the hook so tests don't crash. +// MinerActionsMenu components import hooks from the removed fleet store slice. +// Mock the entire menu components so they don't render real action menus. +vi.mock("@/protoFleet/features/fleetManagement/components/MinerActionsMenu", () => ({ + default: () => null, +})); +vi.mock("@/protoFleet/features/fleetManagement/components/MinerActionsMenu/SingleMinerActionsMenu", () => ({ + default: () => null, +})); + +const mockGetActiveBatches = vi.fn(() => []); + +const createMinerSnapshot = (deviceIdentifier: string, pairingStatus = PairingStatus.PAIRED): MinerStateSnapshot => + create(MinerStateSnapshotSchema, { + deviceIdentifier, + name: deviceIdentifier, + macAddress: "", + ipAddress: "", + deviceStatus: DeviceStatus.ONLINE, + pairingStatus, + hashrate: [], + efficiency: [], + powerUsage: [], + temperature: [], + url: "", + model: "", + firmwareVersion: "", + }); + +/** Auto-generates miners map from minerIds when miners prop is not provided. */ +const autoMiners = (minerIds: string[]): Record => + Object.fromEntries(minerIds.map((id) => [id, createMinerSnapshot(id)])); + +const renderMinerList = ( + props: Omit[0], "miners" | "errorsByDevice" | "errorsLoaded" | "getActiveBatches"> & + Partial[0], "miners" | "errorsByDevice" | "errorsLoaded" | "getActiveBatches">>, + initialEntries?: string[], +) => { + const Router = initialEntries ? MemoryRouter : BrowserRouter; + const routerProps = initialEntries ? { initialEntries } : {}; + const fullProps = { + errorsByDevice: {} as Record, + errorsLoaded: true, + getActiveBatches: mockGetActiveBatches, + ...props, + miners: props.miners ?? autoMiners(props.minerIds ?? []), + }; + + return render( + + + , + ); +}; + +const LocationDisplay = () => { + const location = useLocation(); + + return
{location.search}
; +}; + +const isModelColumnVisible = (preferences: { columns: { id: string; visible: boolean }[] }) => + preferences.columns.find((column) => column.id === "model")?.visible ?? false; + +const PreferenceStorageKeyProbe = ({ username }: { username: string }) => { + const { preferences, setPreferences } = useMinerTableColumnPreferences(username); + const previousUsername = useRef(username); + + useLayoutEffect(() => { + if (previousUsername.current === username) { + return; + } + + previousUsername.current = username; + setPreferences(preferences); + }, [preferences, setPreferences, username]); + + return
{String(isModelColumnVisible(preferences))}
; +}; + +describe("MinerList", () => { + beforeEach(() => { + vi.clearAllMocks(); + window.history.pushState({}, "", "/"); + localStorage.clear(); + useFleetStore.setState((state) => ({ + auth: { + ...state.auth, + username: "", + }, + })); + }); + + const getColumnHeaders = () => + within(screen.getByTestId("list-header")) + .getAllByRole("columnheader") + .map((header) => header.textContent?.trim() ?? "") + .filter(Boolean); + + describe("miner count subtitle", () => { + it("shows total miner count", () => { + renderMinerList({ + title: "Miners", + minerIds: [], + totalMiners: 14, + onAddMiners: vi.fn(), + loading: true, + }); + + expect(screen.getByText("14 miners")).toBeInTheDocument(); + }); + + it("shows 'X of Y miners' when filters are active and filtered count differs from total", () => { + renderMinerList( + { + title: "Miners", + minerIds: [], + totalMiners: 5, + totalUnfilteredMiners: 14, + onAddMiners: vi.fn(), + loading: true, + }, + ["/?status=hashing"], + ); + + expect(screen.getByText("5 of 14 miners")).toBeInTheDocument(); + }); + + it("shows total count when filters are active but filtered count equals total", () => { + renderMinerList( + { + title: "Miners", + minerIds: [], + totalMiners: 14, + totalUnfilteredMiners: 14, + onAddMiners: vi.fn(), + loading: true, + }, + ["/?status=hashing"], + ); + + expect(screen.getByText("14 miners")).toBeInTheDocument(); + }); + }); + + describe("export csv", () => { + it("renders an export button and calls the export handler", async () => { + const user = userEvent.setup(); + const onExportCsv = vi.fn(); + + renderMinerList({ + title: "Miners", + minerIds: ["m1"], + totalMiners: 1, + onAddMiners: vi.fn(), + onExportCsv, + loading: false, + }); + + await user.click(screen.getByRole("button", { name: "Export CSV" })); + + expect(onExportCsv).toHaveBeenCalledTimes(1); + }); + + it("disables the export button while export is in progress", () => { + renderMinerList({ + title: "Miners", + minerIds: ["m1"], + totalMiners: 1, + onAddMiners: vi.fn(), + exportCsvLoading: true, + loading: false, + }); + + expect(screen.getByRole("button", { name: "Export CSV" })).toBeDisabled(); + }); + + it("disables the export button when there are no miners", () => { + renderMinerList( + { + title: "Miners", + minerIds: [], + totalMiners: 0, + onAddMiners: vi.fn(), + loading: false, + }, + ["/?status=hashing"], + ); + + expect(screen.getByRole("button", { name: "Export CSV" })).toBeDisabled(); + }); + }); + + describe("manage columns", () => { + it("opens the manage columns modal", async () => { + const user = userEvent.setup(); + + renderMinerList({ + title: "Miners", + minerIds: ["m1"], + totalMiners: 1, + onAddMiners: vi.fn(), + loading: false, + }); + + await user.click(screen.getByRole("button", { name: "Manage columns" })); + + expect(screen.getByTestId("manage-columns-modal")).toBeInTheDocument(); + expect( + screen.getByText("Choose which data to display and rearrange columns to match your workflow."), + ).toBeInTheDocument(); + expect(screen.getByTestId("manage-columns-reorder-model").firstChild).toHaveClass("w-4", "h-4", "shrink-0"); + }); + + it("saves hidden columns for the current user and reapplies them on rerender", async () => { + const user = userEvent.setup(); + + useFleetStore.setState((state) => ({ + auth: { + ...state.auth, + username: "alice", + }, + })); + + const { rerender } = render( + + + , + ); + + expect(getColumnHeaders()).toContain("Model"); + + await user.click(screen.getByRole("button", { name: "Manage columns" })); + await user.click(screen.getByRole("checkbox", { name: "Toggle Model column" })); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(getColumnHeaders()).not.toContain("Model"); + + rerender( + + + , + ); + + expect(getColumnHeaders()).not.toContain("Model"); + }); + + it("keeps the modal draft in sync when the active user changes while it is open", async () => { + const user = userEvent.setup(); + + localStorage.setItem( + getMinerTableColumnPreferencesStorageKey("alice"), + JSON.stringify({ + columns: [ + { id: "groups", visible: true }, + { id: "model", visible: false }, + ], + }), + ); + + useFleetStore.setState((state) => ({ + auth: { + ...state.auth, + username: "alice", + }, + })); + + renderMinerList({ + title: "Miners", + minerIds: ["m1"], + totalMiners: 1, + onAddMiners: vi.fn(), + loading: false, + }); + + await user.click(screen.getByRole("button", { name: "Manage columns" })); + expect(screen.getByRole("checkbox", { name: "Toggle Model column" })).not.toBeChecked(); + + act(() => { + useFleetStore.setState((state) => ({ + auth: { + ...state.auth, + username: "bob", + }, + })); + }); + + expect(screen.getByRole("checkbox", { name: "Toggle Model column" })).toBeChecked(); + + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(localStorage.getItem(getMinerTableColumnPreferencesStorageKey("bob"))).toBeNull(); + }); + + it("switches to the new user's preferences before layout effects can resave stale state", () => { + localStorage.setItem( + getMinerTableColumnPreferencesStorageKey("alice"), + JSON.stringify({ + columns: [ + { id: "groups", visible: true }, + { id: "model", visible: false }, + ], + }), + ); + + const { rerender } = render(); + + expect(screen.getByTestId("preference-probe-model-visible")).toHaveTextContent("false"); + + rerender(); + + expect(screen.getByTestId("preference-probe-model-visible")).toHaveTextContent("true"); + expect(localStorage.getItem(getMinerTableColumnPreferencesStorageKey("bob"))).toBeNull(); + }); + + it("keeps the table usable when persistence writes fail while saving preferences", async () => { + const user = userEvent.setup(); + + useFleetStore.setState((state) => ({ + auth: { + ...state.auth, + username: "alice", + }, + })); + + renderMinerList({ + title: "Miners", + minerIds: ["m1"], + totalMiners: 1, + onAddMiners: vi.fn(), + loading: false, + }); + + await user.click(screen.getByRole("button", { name: "Manage columns" })); + await user.click(screen.getByRole("checkbox", { name: "Toggle Model column" })); + + const setItemSpy = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("quota exceeded"); + }); + + try { + await user.click(screen.getByRole("button", { name: "Save" })); + } finally { + setItemSpy.mockRestore(); + } + + expect(getColumnHeaders()).not.toContain("Model"); + }); + + it("clears the active sort when the saved preferences hide the sorted column", async () => { + const user = userEvent.setup(); + + render( + + + + , + ); + + expect(screen.getByTestId("location-display")).toHaveTextContent("?sort=model&dir=asc"); + + await user.click(screen.getByRole("button", { name: "Manage columns" })); + await user.click(screen.getByRole("checkbox", { name: "Toggle Model column" })); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(getColumnHeaders()).not.toContain("Model"); + expect(screen.getByTestId("location-display").textContent).toBe(""); + }); + + it("clears a hidden URL sort when stored preferences load on first render", async () => { + localStorage.setItem( + getMinerTableColumnPreferencesStorageKey("alice"), + JSON.stringify({ + columns: [ + { id: "groups", visible: true }, + { id: "model", visible: false }, + ], + }), + ); + + useFleetStore.setState((state) => ({ + auth: { + ...state.auth, + username: "alice", + }, + })); + + render( + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("location-display").textContent).toBe(""); + }); + + expect(getColumnHeaders()).not.toContain("Model"); + }); + + it("clears a hidden URL sort when the active user changes", async () => { + localStorage.setItem( + getMinerTableColumnPreferencesStorageKey("bob"), + JSON.stringify({ + columns: [ + { id: "groups", visible: true }, + { id: "model", visible: false }, + ], + }), + ); + + useFleetStore.setState((state) => ({ + auth: { + ...state.auth, + username: "alice", + }, + })); + + render( + + + + , + ); + + expect(screen.getByTestId("location-display")).toHaveTextContent("?sort=model&dir=asc"); + expect(getColumnHeaders()).toContain("Model"); + + act(() => { + useFleetStore.setState((state) => ({ + auth: { + ...state.auth, + username: "bob", + }, + })); + }); + + await waitFor(() => { + expect(screen.getByTestId("location-display").textContent).toBe(""); + }); + + expect(getColumnHeaders()).not.toContain("Model"); + }); + + it("resets column preferences back to the default layout", async () => { + const user = userEvent.setup(); + + useFleetStore.setState((state) => ({ + auth: { + ...state.auth, + username: "alice", + }, + })); + + localStorage.setItem( + getMinerTableColumnPreferencesStorageKey("alice"), + JSON.stringify({ + columns: [ + { id: "groups", visible: true }, + { id: "model", visible: false }, + ], + }), + ); + + renderMinerList({ + title: "Miners", + minerIds: ["m1"], + totalMiners: 1, + onAddMiners: vi.fn(), + loading: false, + }); + + expect(getColumnHeaders()).not.toContain("Model"); + + await user.click(screen.getByRole("button", { name: "Manage columns" })); + await user.click(screen.getByRole("button", { name: "Reset to defaults" })); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(getColumnHeaders()).toContain("Model"); + }); + + it("keeps the table usable when clearing persisted defaults fails", async () => { + const user = userEvent.setup(); + + useFleetStore.setState((state) => ({ + auth: { + ...state.auth, + username: "alice", + }, + })); + + localStorage.setItem( + getMinerTableColumnPreferencesStorageKey("alice"), + JSON.stringify({ + columns: [ + { id: "groups", visible: true }, + { id: "model", visible: false }, + ], + }), + ); + + renderMinerList({ + title: "Miners", + minerIds: ["m1"], + totalMiners: 1, + onAddMiners: vi.fn(), + loading: false, + }); + + await user.click(screen.getByRole("button", { name: "Manage columns" })); + await user.click(screen.getByRole("button", { name: "Reset to defaults" })); + + const removeItemSpy = vi.spyOn(Storage.prototype, "removeItem").mockImplementation(() => { + throw new Error("storage denied"); + }); + + try { + await user.click(screen.getByRole("button", { name: "Save" })); + } finally { + removeItemSpy.mockRestore(); + } + + expect(getColumnHeaders()).toContain("Model"); + }); + + it("loads column preferences per user without leaking between accounts", async () => { + localStorage.setItem( + getMinerTableColumnPreferencesStorageKey("alice"), + JSON.stringify({ + columns: [ + { id: "groups", visible: true }, + { id: "model", visible: false }, + ], + }), + ); + + useFleetStore.setState((state) => ({ + auth: { + ...state.auth, + username: "alice", + }, + })); + + renderMinerList({ + title: "Miners", + minerIds: ["m1"], + totalMiners: 1, + onAddMiners: vi.fn(), + loading: false, + }); + + expect(getColumnHeaders()).not.toContain("Model"); + + act(() => { + useFleetStore.setState((state) => ({ + auth: { + ...state.auth, + username: "bob", + }, + })); + }); + + expect(getColumnHeaders()).toContain("Model"); + + act(() => { + useFleetStore.setState((state) => ({ + auth: { + ...state.auth, + username: "alice", + }, + })); + }); + + expect(getColumnHeaders()).not.toContain("Model"); + }); + }); + + describe("pagination footer", () => { + it("shows correct range for the first page", () => { + renderMinerList({ + title: "Miners", + minerIds: ["m1", "m2", "m3"], + totalMiners: 10, + currentPage: 0, + onAddMiners: vi.fn(), + loading: false, + }); + + expect(screen.getByText("Showing 1–3 of 10 miners")).toBeInTheDocument(); + }); + + it("shows correct range for a subsequent page", () => { + renderMinerList({ + title: "Miners", + minerIds: ["m1", "m2"], + totalMiners: 102, + currentPage: 1, + pageSize: 100, + onAddMiners: vi.fn(), + loading: false, + }); + + expect(screen.getByText("Showing 101–102 of 102 miners")).toBeInTheDocument(); + }); + + it("does not show pagination footer when there are no miners", () => { + renderMinerList({ + title: "Miners", + minerIds: [], + totalMiners: 0, + onAddMiners: vi.fn(), + loading: false, + }); + + expect(screen.queryByText(/Showing/)).not.toBeInTheDocument(); + }); + + it("does not show pagination footer while loading", () => { + renderMinerList({ + title: "Miners", + minerIds: ["m1"], + totalMiners: 5, + currentPage: 0, + onAddMiners: vi.fn(), + loading: true, + }); + + expect(screen.queryByText(/Showing/)).not.toBeInTheDocument(); + }); + + it("disables the prev button on the first page", () => { + renderMinerList({ + title: "Miners", + minerIds: ["m1"], + totalMiners: 5, + currentPage: 0, + hasPreviousPage: false, + onPrevPage: vi.fn(), + onAddMiners: vi.fn(), + loading: false, + }); + + expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled(); + }); + + it("disables the next button on the last page", () => { + renderMinerList({ + title: "Miners", + minerIds: ["m1"], + totalMiners: 5, + hasNextPage: false, + onNextPage: vi.fn(), + onAddMiners: vi.fn(), + loading: false, + }); + + expect(screen.getByRole("button", { name: "Next page" })).toBeDisabled(); + }); + + it("calls onPrevPage when prev button is clicked", async () => { + const user = userEvent.setup(); + const onPrevPage = vi.fn(); + + renderMinerList({ + title: "Miners", + minerIds: ["m1"], + totalMiners: 5, + hasPreviousPage: true, + onPrevPage, + onAddMiners: vi.fn(), + loading: false, + }); + + await user.click(screen.getByRole("button", { name: "Previous page" })); + + expect(onPrevPage).toHaveBeenCalledTimes(1); + }); + + it("calls onNextPage when next button is clicked", async () => { + const user = userEvent.setup(); + const onNextPage = vi.fn(); + + renderMinerList({ + title: "Miners", + minerIds: ["m1"], + totalMiners: 5, + hasNextPage: true, + onNextPage, + onAddMiners: vi.fn(), + loading: false, + }); + + await user.click(screen.getByRole("button", { name: "Next page" })); + + expect(onNextPage).toHaveBeenCalledTimes(1); + }); + + it("scrolls to top when next button is clicked", async () => { + const user = userEvent.setup(); + const scrollIntoView = vi.fn(); + + renderMinerList({ + title: "Miners", + minerIds: ["m1"], + totalMiners: 5, + hasNextPage: true, + onNextPage: vi.fn(), + onAddMiners: vi.fn(), + loading: false, + }); + + screen.getByText("Miners").closest("div")!.scrollIntoView = scrollIntoView; + + await user.click(screen.getByRole("button", { name: "Next page" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "start" }); + }); + + it("scrolls to top when prev button is clicked", async () => { + const user = userEvent.setup(); + const scrollIntoView = vi.fn(); + + renderMinerList({ + title: "Miners", + minerIds: ["m1"], + totalMiners: 5, + hasPreviousPage: true, + onPrevPage: vi.fn(), + onAddMiners: vi.fn(), + loading: false, + }); + + screen.getByText("Miners").closest("div")!.scrollIntoView = scrollIntoView; + + await user.click(screen.getByRole("button", { name: "Previous page" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "start" }); + }); + + it("adds bottom padding to pagination when miners are selected", async () => { + const user = userEvent.setup(); + + renderMinerList({ + title: "Miners", + minerIds: ["m1", "m2"], + totalMiners: 10, + currentPage: 0, + onAddMiners: vi.fn(), + loading: false, + }); + + const rowCheckboxes = screen.getAllByTestId("checkbox"); + await user.click(rowCheckboxes[0].querySelector("input[type='checkbox']") as HTMLInputElement); + + expect(screen.getByTestId("miners-pagination")).toHaveClass("pb-24"); + expect(screen.getByTestId("mock-miner-list-selection-mode")).toHaveTextContent("subset"); + expect(screen.getByTestId("mock-miner-list-selection-count")).toHaveTextContent("1"); + }); + + it("keeps header checkbox selection scoped to the current page", async () => { + const user = userEvent.setup(); + + renderMinerList({ + title: "Miners", + minerIds: ["m1", "m2"], + totalMiners: 10, + currentPage: 0, + onAddMiners: vi.fn(), + loading: false, + }); + + const selectAllCheckbox = screen + .getByTestId("list-header") + .querySelector("input[type='checkbox']") as HTMLInputElement; + + await user.click(selectAllCheckbox); + + expect(screen.getByTestId("mock-miner-list-selection-mode")).toHaveTextContent("subset"); + expect(screen.getByTestId("mock-miner-list-selected-miners")).toHaveTextContent("m1,m2"); + expect(screen.getByTestId("mock-miner-list-selection-count")).toHaveTextContent("2"); + }); + + it("hides action-bar select controls when filters are active", async () => { + const user = userEvent.setup(); + + renderMinerList( + { + title: "Miners", + minerIds: ["m1", "m2"], + totalMiners: 10, + currentPage: 0, + onAddMiners: vi.fn(), + loading: false, + }, + ["/?status=hashing"], + ); + + const rowCheckboxes = screen.getAllByTestId("checkbox"); + await user.click(rowCheckboxes[0].querySelector("input[type='checkbox']") as HTMLInputElement); + + expect(screen.getByTestId("mock-miner-list-action-bar")).toBeInTheDocument(); + expect(screen.queryByTestId("mock-action-bar-select-all")).not.toBeInTheDocument(); + expect(screen.queryByTestId("mock-action-bar-select-none")).not.toBeInTheDocument(); + expect(screen.getByTestId("mock-miner-list-selection-mode")).toHaveTextContent("subset"); + expect(screen.getByTestId("mock-miner-list-selection-count")).toHaveTextContent("1"); + }); + + it("clears bulk selection when the page changes and does not restore it when returning", async () => { + const user = userEvent.setup(); + + const { rerender } = renderMinerList({ + title: "Miners", + minerIds: ["m1", "m2"], + totalMiners: 4, + currentPage: 0, + pageSize: 2, + onAddMiners: vi.fn(), + loading: false, + }); + + const rowCheckboxes = screen.getAllByTestId("checkbox"); + await user.click(rowCheckboxes[0].querySelector("input[type='checkbox']") as HTMLInputElement); + await user.click(screen.getByTestId("mock-action-bar-select-all")); + + expect(screen.getByTestId("mock-miner-list-selection-mode")).toHaveTextContent("all"); + expect(screen.getByTestId("mock-miner-list-selection-count")).toHaveTextContent("4"); + + rerender( + + + , + ); + + expect(screen.queryByTestId("mock-miner-list-action-bar")).not.toBeInTheDocument(); + + rerender( + + + , + ); + + expect(screen.queryByTestId("mock-miner-list-action-bar")).not.toBeInTheDocument(); + }); + + it("recomputes selectable miners when a row becomes disabled between renders", async () => { + const user = userEvent.setup(); + + const initialMiners = { + m1: createMinerSnapshot("m1"), + m2: createMinerSnapshot("m2"), + }; + + const { rerender } = renderMinerList({ + title: "Miners", + minerIds: ["m1", "m2"], + miners: initialMiners, + totalMiners: 2, + totalDisabledMiners: 0, + currentPage: 0, + onAddMiners: vi.fn(), + loading: false, + }); + + const rowCheckboxes = screen.getAllByTestId("checkbox"); + await user.click(rowCheckboxes[0].querySelector("input[type='checkbox']") as HTMLInputElement); + + const updatedMiners = { + ...initialMiners, + m2: createMinerSnapshot("m2", PairingStatus.AUTHENTICATION_NEEDED), + }; + + await act(async () => { + rerender( + + + , + ); + }); + + await user.click(screen.getByTestId("mock-action-bar-select-all")); + + expect(screen.getByTestId("mock-miner-list-selected-miners")).toHaveTextContent("m1"); + }); + }); + + describe("row click navigation", () => { + it("opens miner URL in a new tab when miner has a URL", async () => { + const user = userEvent.setup(); + const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); + + const snapshot = createMinerSnapshot("m1"); + snapshot.url = "https://192.168.1.100"; + + renderMinerList({ + title: "Miners", + minerIds: ["m1"], + miners: { m1: snapshot }, + totalMiners: 1, + onAddMiners: vi.fn(), + loading: false, + }); + + const row = screen.getByTestId("list-row"); + await user.click(row); + + expect(openSpy).toHaveBeenCalledWith("https://192.168.1.100", "_blank", "noopener,noreferrer"); + openSpy.mockRestore(); + }); + + it("does not open a new tab when miner has no URL", async () => { + const user = userEvent.setup(); + const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); + + renderMinerList({ + title: "Miners", + minerIds: ["m1"], + miners: { m1: createMinerSnapshot("m1") }, + totalMiners: 1, + onAddMiners: vi.fn(), + loading: false, + }); + + const row = screen.getByTestId("list-row"); + await user.click(row); + + expect(openSpy).not.toHaveBeenCalled(); + openSpy.mockRestore(); + }); + }); + + describe("null state", () => { + it("should show null state when no miners are paired", () => { + const onAddMiners = vi.fn(); + + renderMinerList({ + title: "Miners", + minerIds: [], + totalMiners: 0, + onAddMiners, + }); + + expect(screen.getByText("You haven't paired any miners")).toBeInTheDocument(); + expect(screen.getByText("Add miners to your fleet to get started.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Get started" })).toBeInTheDocument(); + // List header and "Add miners" button should not be visible when showing null state + expect(screen.queryByText("Miners")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Add miners" })).not.toBeInTheDocument(); + }); + + it("should call onAddMiners when Get started button is clicked", async () => { + const user = userEvent.setup(); + const onAddMiners = vi.fn(); + + renderMinerList({ + title: "Miners", + minerIds: [], + totalMiners: 0, + onAddMiners, + }); + + await user.click(screen.getByRole("button", { name: "Get started" })); + + expect(onAddMiners).toHaveBeenCalledTimes(1); + }); + + it("should not show null state when loading", () => { + const onAddMiners = vi.fn(); + + renderMinerList({ + title: "Miners", + minerIds: [], + totalMiners: 0, + onAddMiners, + loading: true, + }); + + expect(screen.queryByText("You haven't paired any miners")).not.toBeInTheDocument(); + }); + + it("should not show null state when filters are active and no items match", () => { + const onAddMiners = vi.fn(); + + renderMinerList( + { + title: "Miners", + minerIds: [], + totalMiners: 0, + onAddMiners, + }, + ["/?status=hashing"], + ); + + // Null state should not appear when filters are active + expect(screen.queryByText("You haven't paired any miners")).not.toBeInTheDocument(); + // Regular list view should be shown instead + expect(screen.getByText("Miners")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Add miners" })).toBeInTheDocument(); + }); + + it("shows the filtered empty state and clears filters when requested", async () => { + const user = userEvent.setup(); + + render( + + + + , + ); + + expect(screen.getByText("No results")).toBeInTheDocument(); + expect(screen.getByText("Try adjusting or clearing your filters.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Clear all filters" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Clear all filters" })); + + expect(screen.getByTestId("location-display")).toHaveTextContent("?sort=name&dir=desc"); + }); + + it("should not show null state when group filter is active", () => { + renderMinerList( + { + title: "Miners", + minerIds: [], + totalMiners: 0, + onAddMiners: vi.fn(), + }, + ["/?group=1"], + ); + + expect(screen.queryByText("You haven't paired any miners")).not.toBeInTheDocument(); + expect(screen.getByText("Miners")).toBeInTheDocument(); + }); + + it("shows filtered empty state when items are empty but totalMiners is non-zero", () => { + render( + + + , + ); + + expect(screen.getByText("No results")).toBeInTheDocument(); + expect(screen.getByText("Try adjusting or clearing your filters.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Clear all filters" })).toBeInTheDocument(); + expect(screen.queryByText(/Showing/)).not.toBeInTheDocument(); + }); + + it("clears group param along with other filters while preserving sort params", async () => { + const user = userEvent.setup(); + + render( + + + + , + ); + + await user.click(screen.getByRole("button", { name: "Clear all filters" })); + + expect(screen.getByTestId("location-display")).toHaveTextContent("?sort=name&dir=desc"); + }); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerList.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerList.tsx new file mode 100644 index 000000000..1e5c07620 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerList.tsx @@ -0,0 +1,898 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; + +import clsx from "clsx"; +import { create } from "@bufbuild/protobuf"; +import { + componentIssues, + deviceStatusFilterStates, + minerCols, + minerColTitles, + type MinerColumn, + MINERS_PAGE_SIZE, +} from "./constants"; +import ManageColumnsModal from "./ManageColumnsModal"; +import createMinerColConfig from "./minerColConfig"; +import { buildActiveMinerColumns, type MinerTableColumnPreferences } from "./minerTableColumnPreferences"; +import { getColumnForSortField, getDefaultSortDirection, SORTABLE_COLUMNS } from "./sortConfig"; +import { type DeviceListItem } from "./types"; +import useMinerTableColumnPreferences from "./useMinerTableColumnPreferences"; +import type { SortConfig } from "@/protoFleet/api/generated/common/v1/sort_pb"; +import type { DeviceSet } from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; +import { ComponentType } from "@/protoFleet/api/generated/errors/v1/errors_pb"; +import type { ErrorMessage } from "@/protoFleet/api/generated/errors/v1/errors_pb"; +import { + type MinerListFilter, + MinerListFilterSchema, + type MinerStateSnapshot, + PairingStatus, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { DeviceStatus } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import NoFilterResultsEmptyState from "@/protoFleet/components/NoFilterResultsEmptyState"; +import { ProtoFleetStatusModal } from "@/protoFleet/components/StatusModal"; +import AuthenticateFleetModal from "@/protoFleet/features/auth/components/AuthenticateFleetModal"; +import { AuthenticateMiners } from "@/protoFleet/features/auth/components/AuthenticateMiners"; +import PoolSelectionPageWrapper from "@/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage"; +import MinerListActionBar from "@/protoFleet/features/fleetManagement/components/MinerList/MinerListActionBar"; +import type { BatchOperation } from "@/protoFleet/features/fleetManagement/hooks/useBatchOperations"; + +import { + encodeFilterToURL, + parseUrlToActiveFilters, +} from "@/protoFleet/features/fleetManagement/utils/filterUrlParams"; +import { encodeSortToURL, parseSortFromURL } from "@/protoFleet/features/fleetManagement/utils/sortUrlParams"; +import { useUsername } from "@/protoFleet/store"; + +import { ChevronDown, LogoAlt, Slider } from "@/shared/assets/icons"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import Header from "@/shared/components/Header"; +import List from "@/shared/components/List"; +import { type SelectionMode } from "@/shared/components/List"; +import { ActiveFilters, FilterItem } from "@/shared/components/List/Filters/types"; +import { type SortDirection } from "@/shared/components/List/types"; +import ProgressCircular from "@/shared/components/ProgressCircular"; +import { Breakpoint } from "@/shared/constants/breakpoints"; + +type FleetCredentials = { username: string; password: string }; + +type MinerModalFlow = + | { kind: "closed" } + | { kind: "authenticate-miners"; deviceIdentifier: string } + | { kind: "authenticate-fleet"; deviceIdentifier: string; deviceStatus?: DeviceStatus } + | { + kind: "pool-selection"; + deviceIdentifier: string; + deviceStatus?: DeviceStatus; + credentials: FleetCredentials; + } + | { kind: "status-modal"; deviceIdentifier: string }; + +type MinerListProps = { + title: string; + minerIds: string[]; + miners: Record; + errorsByDevice: Record; + errorsLoaded: boolean; + getActiveBatches: (deviceId: string) => BatchOperation[]; + /** Monotonic counter — changes when batch state mutates, used to invalidate deviceItems memo. */ + batchStateVersion?: number; + listClassName?: string; + paddingLeft?: Partial>; + onAddMiners: () => void; + totalMiners?: number; + /** + * Total unfiltered miner count for the "X of Y miners" subtitle display. + */ + totalUnfilteredMiners?: number; + /** + * Total number of disabled miners (requiring authentication). + * Used to calculate selectable count: totalMiners - totalDisabledMiners + */ + totalDisabledMiners?: number; + /** + * Optional callback to attach refs to list row elements. + * Used for viewport visibility tracking. + */ + itemRef?: (itemKey: string, element: HTMLTableRowElement | null) => void; + /** + * Whether the list is loading. Shows a spinner in place of list items. + */ + loading?: boolean; + /** + * Number of items per page. Used to compute the displayed item range (e.g., "Showing 1–100"). + * Must match the pageSize passed to useFleet. + */ + pageSize?: number; + /** + * Current page index (0-based) for pagination display. + */ + currentPage?: number; + /** + * Whether there is a previous page to navigate to. + */ + hasPreviousPage?: boolean; + /** + * Whether there is a next page to navigate to. + */ + hasNextPage?: boolean; + /** + * Callback to navigate to the next page. + */ + onNextPage?: () => void; + /** + * Callback to navigate to the previous page. + */ + onPrevPage?: () => void; + /** + * Current sort configuration from URL/store. + * Passed down from parent to enable controlled sorting. + */ + currentSort?: { field: MinerColumn; direction: SortDirection }; + /** + * Callback when user clicks a sortable column header. + * Parent handles URL update and API request. + */ + onSort?: (field: MinerColumn, direction: SortDirection) => void; + /** + * Available model names for the model filter dropdown. + * Comes from the API response. + */ + availableModels?: string[]; + /** + * Available groups for the group filter dropdown. + */ + availableGroups?: DeviceSet[]; + /** + * Available racks for the rack filter dropdown. + */ + availableRacks?: DeviceSet[]; + /** + * Exports the full paired miner list as CSV. + */ + onExportCsv?: () => void | Promise; + /** + * Whether a CSV export is currently in progress. + */ + exportCsvLoading?: boolean; + /** Active server-side filter — forwarded for "all" mode delete */ + currentFilter?: MinerListFilter; + /** Current server-side sort — forwarded for bulk actions that depend on table order. */ + currentSortConfig?: SortConfig; + /** Callback to trigger a miner list refresh (e.g., after rename or unpair). */ + onRefetchMiners?: () => void; + /** Callback to update a visible worker name immediately after a successful save. */ + onWorkerNameUpdated?: (deviceIdentifier: string, workerName: string) => void; + /** Callback to notify that pairing/auth completed (triggers pool polling in CompleteSetup). */ + onPairingCompleted?: () => void; +}; + +type ScopedMinerListBodyProps = { + activeCols: MinerColumn[]; + deviceItems: DeviceListItem[]; + minerColConfig: ReturnType; + filters: FilterItem[]; + handleServerFilter: (filters: ActiveFilters) => Promise; + initialActiveFilters: ActiveFilters; + listClassName?: string; + paddingLeft?: Partial>; + totalMiners?: number; + totalDisabledMiners: number; + itemRef?: (itemKey: string, element: HTMLTableRowElement | null) => void; + hasActiveFilters: boolean; + onAddMiners: () => void; + onExportCsv?: () => void | Promise; + exportCsvLoading?: boolean; + onOpenManageColumns: () => void; + handleClearFilters: () => void; + isRowDisabled: (item: DeviceListItem) => boolean; + currentFilter?: MinerListFilter; + currentSortConfig?: SortConfig; + currentSort?: { field: MinerColumn; direction: SortDirection }; + onSort?: (field: MinerColumn, direction: SortDirection) => void; + firstItemIndex: number; + lastItemIndex: number; + shouldRenderPagination: boolean; + hasPreviousPage: boolean; + hasNextPage: boolean; + handlePrevPage: () => void; + handleNextPage: () => void; + onRowClick: (item: DeviceListItem, index: number) => void; + miners?: Record; + minerIds?: string[]; + onRefetchMiners?: () => void; + onWorkerNameUpdated?: (deviceIdentifier: string, workerName: string) => void; +}; + +const ScopedMinerListBody = ({ + activeCols, + deviceItems, + minerColConfig, + filters, + handleServerFilter, + initialActiveFilters, + listClassName, + paddingLeft, + totalMiners, + totalDisabledMiners, + itemRef, + hasActiveFilters, + onAddMiners, + onExportCsv, + exportCsvLoading = false, + onOpenManageColumns, + handleClearFilters, + isRowDisabled, + currentFilter, + currentSortConfig, + currentSort, + onSort, + firstItemIndex, + lastItemIndex, + shouldRenderPagination, + hasPreviousPage, + hasNextPage, + handlePrevPage, + handleNextPage, + onRowClick, + miners: minersProp, + minerIds: minerIdsProp, + onRefetchMiners, + onWorkerNameUpdated, +}: ScopedMinerListBodyProps) => { + const [selectedMinerIds, setSelectedMinerIds] = useState([]); + const [selectionMode, setSelectionMode] = useState("none"); + const sortableColumnsSet = useMemo(() => new Set(SORTABLE_COLUMNS), []); + + const currentPageSelectableMinerIds = deviceItems + .filter((item) => !isRowDisabled(item)) + .map((item) => item.deviceIdentifier); + + const handleSelectAllMiners = useCallback(() => { + setSelectedMinerIds(currentPageSelectableMinerIds); + setSelectionMode("all"); + }, [currentPageSelectableMinerIds]); + + const handleSelectNoneMiners = useCallback(() => { + setSelectedMinerIds([]); + setSelectionMode("none"); + }, []); + + return ( + <> + + activeCols={activeCols} + colTitles={minerColTitles} + colConfig={minerColConfig} + filters={filters} + onServerFilter={handleServerFilter} + items={deviceItems} + itemKey={"deviceIdentifier"} + customSelectedItems={selectedMinerIds} + customSetSelectedItems={setSelectedMinerIds} + customSelectionMode={selectionMode} + itemSelectable + pageScopedSelection + hasActiveFilters={hasActiveFilters} + headerControls={ +
+
+ } + renderActionBar={(selectedItems, clearSelection, currentSelectionMode, totalSelectable) => ( +
+ +
+ )} + containerClassName={listClassName} + tableClassName="mb-4 inline-table w-max !min-w-fit !table-fixed" + paddingLeft={paddingLeft} + paddingRight={paddingLeft} + overflowContainer={false} + applyColumnWidthsToCells + total={totalMiners} + totalDisabled={totalDisabledMiners} + hideTotal + itemName={{ singular: "miner", plural: "miners" }} + itemRef={itemRef} + initialActiveFilters={initialActiveFilters} + onSelectionModeChange={setSelectionMode} + isRowDisabled={isRowDisabled} + columnsExemptFromDisabledStyling={new Set([minerCols.name, minerCols.status, minerCols.issues])} + sortableColumns={sortableColumnsSet} + currentSort={currentSort} + onSort={onSort} + getDefaultSortDirection={getDefaultSortDirection} + onRowClick={onRowClick} + emptyStateRow={ + totalMiners === 0 || deviceItems.length === 0 ? ( + + ) : undefined + } + /> + + {shouldRenderPagination && ( +
+ + Showing {firstItemIndex}–{lastItemIndex} of {totalMiners} miners + +
+
+
+ )} + + ); +}; + +const MinerList = ({ + title, + minerIds = [], + miners, + errorsByDevice, + errorsLoaded, + getActiveBatches, + batchStateVersion, + listClassName, + paddingLeft, + onAddMiners, + totalMiners, + totalUnfilteredMiners, + totalDisabledMiners = 0, + itemRef, + loading = false, + pageSize = MINERS_PAGE_SIZE, + currentPage = 0, + hasPreviousPage = false, + hasNextPage = false, + onNextPage, + onPrevPage, + currentSort, + onSort, + availableModels = [], + availableGroups = [], + availableRacks = [], + onExportCsv, + exportCsvLoading = false, + currentFilter, + currentSortConfig, + onRefetchMiners, + onWorkerNameUpdated, + onPairingCompleted, +}: MinerListProps) => { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const username = useUsername(); + const { preferences: columnPreferences, setPreferences: setColumnPreferences } = + useMinerTableColumnPreferences(username); + + const [modalFlow, setModalFlow] = useState({ kind: "closed" }); + const [showManageColumnsModal, setShowManageColumnsModal] = useState(false); + + const topRef = useRef(null); + + const scrollToTop = useCallback(() => { + topRef.current?.scrollIntoView?.({ behavior: "smooth", block: "start" }); + }, []); + + const handleNextPage = useCallback(() => { + scrollToTop(); + onNextPage?.(); + }, [scrollToTop, onNextPage]); + + const handlePrevPage = useCallback(() => { + scrollToTop(); + onPrevPage?.(); + }, [scrollToTop, onPrevPage]); + + const deviceItems: DeviceListItem[] = useMemo( + () => + minerIds + .filter((id) => miners[id]) // skip if miner not yet loaded + .map((id) => ({ + deviceIdentifier: id, + miner: miners[id], + errors: errorsByDevice[id] ?? [], + activeBatches: getActiveBatches(id), + })), + // getActiveBatches identity changes on every dispatch but batchStateVersion + // is the canonical trigger — suppress the lint warning for the unstable callback. + // eslint-disable-next-line react-hooks/exhaustive-deps + [minerIds, miners, errorsByDevice, batchStateVersion], + ); + + const disabledMinerIdSet = useMemo( + () => new Set(minerIds.filter((id) => miners[id]?.pairingStatus === PairingStatus.AUTHENTICATION_NEEDED)), + [minerIds, miners], + ); + const isRowDisabled = useCallback( + (item: DeviceListItem) => disabledMinerIdSet.has(item.deviceIdentifier), + [disabledMinerIdSet], + ); + + const initialActiveFilters = useMemo(() => parseUrlToActiveFilters(searchParams), [searchParams]); + + // Refs for values that change frequently but are only read at call/render time. + // Keeps callbacks and minerColConfig stable across polls. + const minersRef = useRef(miners); + minersRef.current = miners; + const onRefetchMinersRef = useRef(onRefetchMiners); + onRefetchMinersRef.current = onRefetchMiners; + const onWorkerNameUpdatedRef = useRef(onWorkerNameUpdated); + onWorkerNameUpdatedRef.current = onWorkerNameUpdated; + + const closeModalFlow = useCallback(() => { + setModalFlow({ kind: "closed" }); + }, []); + + const handleOpenStatusFlow = useCallback( + (deviceIdentifier: string) => { + const miner = minersRef.current[deviceIdentifier]; + if (!miner) return; + + const needsAuthentication = miner.pairingStatus === PairingStatus.AUTHENTICATION_NEEDED; + const needsMiningPool = miner.deviceStatus === DeviceStatus.NEEDS_MINING_POOL; + + if (needsAuthentication) { + setModalFlow({ kind: "authenticate-miners", deviceIdentifier }); + return; + } + + if (needsMiningPool) { + setModalFlow({ + kind: "authenticate-fleet", + deviceIdentifier, + deviceStatus: miner.deviceStatus, + }); + return; + } + + setModalFlow({ kind: "status-modal", deviceIdentifier }); + }, + // minersRef is stable — read at call time, not memoization time + [], + ); + + const handleFleetAuthenticated = useCallback((username: string, password: string) => { + setModalFlow((current) => { + if (current.kind !== "authenticate-fleet") { + return current; + } + + return { + kind: "pool-selection", + deviceIdentifier: current.deviceIdentifier, + deviceStatus: current.deviceStatus, + credentials: { username, password }, + }; + }); + }, []); + + const handleRowClick = useCallback((item: DeviceListItem) => { + if (item.miner.url) { + window.open(item.miner.url, "_blank", "noopener,noreferrer"); + } + }, []); + const sortColumnFromUrl = useMemo(() => { + const parsedSort = parseSortFromURL(searchParams); + return parsedSort ? getColumnForSortField(parsedSort.field) : undefined; + }, [searchParams]); + const activeSortColumn = currentSort?.field ?? sortColumnFromUrl; + + const minerColConfig = useMemo( + () => + createMinerColConfig({ + onOpenStatusFlow: handleOpenStatusFlow, + availableGroups, + errorsLoaded, + minersRef, + onRefetchMinersRef, + onWorkerNameUpdatedRef, + }), + // handleOpenStatusFlow is stable (reads from minersRef) — only recreate for groups/errors changes + // eslint-disable-next-line react-hooks/exhaustive-deps + [availableGroups, errorsLoaded], + ); + const activeCols = useMemo(() => buildActiveMinerColumns(columnPreferences), [columnPreferences]); + + const hasActiveFilters = useMemo(() => { + return ( + searchParams.has("status") || + searchParams.has("issues") || + searchParams.has("model") || + searchParams.has("group") || + searchParams.has("rack") + ); + }, [searchParams]); + useEffect(() => { + if (!sortColumnFromUrl || activeCols.includes(sortColumnFromUrl)) { + return; + } + + const params = new URLSearchParams(searchParams); + if (!params.has("sort") && !params.has("dir")) { + return; + } + + encodeSortToURL(params, undefined); + navigate({ search: params.toString() ? `?${params.toString()}` : "" }, { replace: true }); + }, [activeCols, navigate, searchParams, sortColumnFromUrl]); + + const selectionFilterKey = useMemo(() => { + const params = new URLSearchParams(); + ["status", "issues", "model"].forEach((key) => { + searchParams + .getAll(key) + .sort() + .forEach((value) => params.append(key, value)); + }); + return params.toString(); + }, [searchParams]); + const selectionScopeKey = useMemo(() => `${selectionFilterKey}:${currentPage}`, [currentPage, selectionFilterKey]); + + const handleClearFilters = useCallback(() => { + const nextSearchParams = new URLSearchParams(searchParams); + nextSearchParams.delete("status"); + nextSearchParams.delete("issues"); + nextSearchParams.delete("model"); + nextSearchParams.delete("group"); + nextSearchParams.delete("rack"); + + const nextSearch = nextSearchParams.toString(); + navigate({ search: nextSearch ? `?${nextSearch}` : "" }, { replace: true }); + }, [navigate, searchParams]); + + const filters = useMemo(() => { + return [ + { + type: "dropdown", + title: "Status", + value: "status", + options: [ + { id: deviceStatusFilterStates.hashing, label: "Hashing" }, + { + id: deviceStatusFilterStates.needsAttention, + label: "Needs Attention", + }, + { id: deviceStatusFilterStates.offline, label: "Offline" }, + { id: deviceStatusFilterStates.sleeping, label: "Sleeping" }, + ], + defaultOptionIds: [], + }, + { + type: "dropdown", + title: "Issues", + value: "issues", + options: [ + { id: componentIssues.controlBoard, label: "Control board issue" }, + { id: componentIssues.fans, label: "Fan issue" }, + { id: componentIssues.hashBoards, label: "Hash board issue" }, + { id: componentIssues.psu, label: "PSU issue" }, + ], + defaultOptionIds: [], + }, + { + type: "dropdown", + title: "Model", + value: "model", + options: availableModels.map((model) => ({ id: model, label: model })), + defaultOptionIds: [], + }, + { + type: "dropdown", + title: "Groups", + value: "group", + options: availableGroups.map((g) => ({ id: String(g.id), label: g.label })), + defaultOptionIds: [], + }, + { + type: "dropdown", + title: "Racks", + value: "rack", + options: availableRacks.map((r) => ({ id: String(r.id), label: r.label })), + defaultOptionIds: [], + }, + ] as FilterItem[]; + }, [availableModels, availableGroups, availableRacks]); + + const handleServerFilter = useCallback( + async (filters: ActiveFilters) => { + const minerFilter = create(MinerListFilterSchema, { + errorComponentTypes: [], + }); + + const statusFilters = filters.dropdownFilters.status; + if (statusFilters !== undefined && statusFilters.length > 0) { + // Only apply status filtering if specific statuses are selected + statusFilters.forEach((filter) => { + switch (filter) { + case deviceStatusFilterStates.hashing: + minerFilter.deviceStatus.push(DeviceStatus.ONLINE); + break; + case deviceStatusFilterStates.needsAttention: + minerFilter.deviceStatus.push(DeviceStatus.ERROR); + minerFilter.deviceStatus.push(DeviceStatus.NEEDS_MINING_POOL); + minerFilter.deviceStatus.push(DeviceStatus.UPDATING); + minerFilter.deviceStatus.push(DeviceStatus.REBOOT_REQUIRED); + break; + case deviceStatusFilterStates.offline: + minerFilter.deviceStatus.push(DeviceStatus.OFFLINE); + break; + case deviceStatusFilterStates.sleeping: + minerFilter.deviceStatus.push(DeviceStatus.INACTIVE); + break; + } + }); + } + // If statusFilters is undefined or empty, don't add any status filter (show all) + + const modelFilters = filters.dropdownFilters.model; + if (modelFilters && modelFilters.length > 0) { + minerFilter.models.push(...modelFilters); + } + const issueFilters = filters.dropdownFilters.issues; + issueFilters?.forEach((issue) => { + switch (issue) { + case componentIssues.controlBoard: + minerFilter.errorComponentTypes.push(ComponentType.CONTROL_BOARD); + break; + case componentIssues.fans: + minerFilter.errorComponentTypes.push(ComponentType.FAN); + break; + case componentIssues.hashBoards: + minerFilter.errorComponentTypes.push(ComponentType.HASH_BOARD); + break; + case componentIssues.psu: + minerFilter.errorComponentTypes.push(ComponentType.PSU); + break; + } + }); + + const groupFilters = filters.dropdownFilters.group; + if (groupFilters && groupFilters.length > 0) { + groupFilters.forEach((id) => { + minerFilter.groupIds.push(BigInt(id)); + }); + } + + const rackFilters = filters.dropdownFilters.rack; + if (rackFilters && rackFilters.length > 0) { + rackFilters.forEach((id) => { + minerFilter.rackIds.push(BigInt(id)); + }); + } + + // Navigate with URL params instead of calling parent callback + // Start fresh with filter params, then preserve existing sort params + const params = encodeFilterToURL(minerFilter); + const sortParam = searchParams.get("sort"); + const dirParam = searchParams.get("dir"); + if (sortParam) params.set("sort", sortParam); + if (dirParam) params.set("dir", dirParam); + navigate(`?${params.toString()}`, { replace: true }); + }, + [navigate, searchParams], + ); + const handleOpenManageColumns = useCallback(() => { + setShowManageColumnsModal(true); + }, []); + const handleCloseManageColumns = useCallback(() => { + setShowManageColumnsModal(false); + }, []); + const handleSaveManageColumns = useCallback( + (preferences: MinerTableColumnPreferences) => { + const activeColumns = buildActiveMinerColumns(preferences); + + setColumnPreferences(preferences); + + if (activeSortColumn && !activeColumns.includes(activeSortColumn)) { + const params = new URLSearchParams(searchParams); + encodeSortToURL(params, undefined); + navigate({ search: params.toString() ? `?${params.toString()}` : "" }, { replace: true }); + } + + setShowManageColumnsModal(false); + }, + [activeSortColumn, navigate, searchParams, setColumnPreferences], + ); + + // Show null state when no miners are paired and not loading + const showNullState = !loading && totalMiners === 0 && !hasActiveFilters; + + if (showNullState) { + return ( +
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+ ); + } + + const firstItemIndex = currentPage * pageSize + 1; + const lastItemIndex = currentPage * pageSize + minerIds.length; + const shouldRenderPagination = + !loading && totalMiners !== undefined && totalMiners > 0 && (minerIds.length > 0 || currentPage > 0); + + return ( + <> +
+

{title}

+
+ +
+ {hasActiveFilters && totalUnfilteredMiners !== undefined && totalMiners !== totalUnfilteredMiners + ? `${totalMiners} of ${totalUnfilteredMiners} miners` + : `${totalMiners ?? 0} miners`} +
+ + {loading ? ( +
+ +
+ ) : ( + + )} + + {showManageColumnsModal ? ( + + ) : null} + + {modalFlow.kind === "authenticate-miners" && ( + + )} + + {modalFlow.kind === "authenticate-fleet" && ( + + )} + + {modalFlow.kind === "pool-selection" && ( + + )} + + {modalFlow.kind === "status-modal" && ( + + )} + + ); +}; + +export default MinerList; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerListActionBar.test.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerListActionBar.test.tsx new file mode 100644 index 000000000..b0db15949 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerListActionBar.test.tsx @@ -0,0 +1,126 @@ +import { fireEvent, render, waitFor } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import MinerListActionBar from "@/protoFleet/features/fleetManagement/components/MinerList/MinerListActionBar"; + +// useMinerActions imports batch operation hooks from the store that were removed +// during the fleet slice refactor. Mock the hook so tests don't crash. +// MinerActionsMenu imports hooks from the removed fleet store slice. +// Mock it so the tests don't crash. +vi.mock("@/protoFleet/features/fleetManagement/components/MinerActionsMenu", () => ({ + default: ({ onActionStart }: { onActionStart?: () => void }) => ( +
+ + +
+ ), +})); + +vi.mock("@/protoFleet/api/usePools", () => ({ + default: () => ({ + pools: [], + validatePool: vi.fn(({ onSuccess }) => { + onSuccess?.(); + }), + validatePoolPending: false, + }), +})); + +describe("Miner list action bar", () => { + const actionBarTestId = "action-bar"; + + const actionBarProps = { + selectedMiners: ["MAC1"], + selectionMode: "subset" as const, + }; + + // TODO: Fix this test - requires mocking useMinerCommand and toast system + // Pre-existing failure unrelated to recent changes + test.skip("hides and displays action bar depending on confirmation dialog visibility", async () => { + const { getByTestId } = render(); + + const actionBarElement = getByTestId(actionBarTestId); + expect(actionBarElement).toBeInTheDocument(); + const actionsMenuButton = getByTestId("actions-menu-button"); + fireEvent.click(actionsMenuButton); + const rebootButton = getByTestId("reboot-popover-button"); + fireEvent.click(rebootButton); + + expect(actionBarElement.classList.contains("invisible")).toBe(true); + + const confirmRebootButton = await waitFor(() => getByTestId("reboot-confirm-button")); + fireEvent.click(confirmRebootButton); + + await waitFor(() => { + expect(actionBarElement.classList.contains("invisible")).toBe(false); + }); + }); + + test("hides action bar when mining pool action is triggered", () => { + const { getByTestId } = render(); + + const actionBarElement = getByTestId(actionBarTestId); + expect(actionBarElement).toBeInTheDocument(); + const actionsMenuButton = getByTestId("actions-menu-button"); + fireEvent.click(actionsMenuButton); + const miningPoolsButton = getByTestId("mining-pool-popover-button"); + fireEvent.click(miningPoolsButton); + + expect(actionBarElement.classList.contains("invisible")).toBe(true); + }); + + test("calls onClearSelection when action bar close button is clicked", () => { + const onClearSelectionMock = vi.fn(); + const { getByTestId } = render(); + + const closeButton = getByTestId("close-button"); + fireEvent.click(closeButton); + + expect(onClearSelectionMock).toHaveBeenCalledOnce(); + }); + + test("does not throw when onClearSelection is not provided", () => { + const { getByTestId } = render(); + + const closeButton = getByTestId("close-button"); + + // Should not throw error when clicking close without onClearSelection prop + expect(() => fireEvent.click(closeButton)).not.toThrow(); + }); + + test("renders select all and select none controls", () => { + const onSelectAll = vi.fn(); + const onSelectNone = vi.fn(); + + const { getAllByTestId } = render( + , + ); + + fireEvent.click(getAllByTestId("select-all-miners-button")[0]); + fireEvent.click(getAllByTestId("select-none-miners-button")[0]); + + expect(onSelectAll).toHaveBeenCalledTimes(1); + expect(onSelectNone).toHaveBeenCalledTimes(1); + }); + + test("only renders selection controls that have handlers", () => { + const onClearSelection = vi.fn(); + const { queryAllByTestId, rerender } = render(); + + expect(queryAllByTestId("select-all-miners-button")).toHaveLength(0); + expect(queryAllByTestId("select-none-miners-button")).toHaveLength(0); + + rerender(); + + expect(queryAllByTestId("select-all-miners-button")).toHaveLength(0); + expect(queryAllByTestId("select-none-miners-button")).toHaveLength(0); + + rerender(); + + expect(queryAllByTestId("select-all-miners-button")).toHaveLength(0); + expect(queryAllByTestId("select-none-miners-button")).toHaveLength(1); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerListActionBar.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerListActionBar.tsx new file mode 100644 index 000000000..47990833c --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerListActionBar.tsx @@ -0,0 +1,119 @@ +import { useEffect, useRef } from "react"; +import type { SortConfig } from "@/protoFleet/api/generated/common/v1/sort_pb"; +import type { + MinerListFilter, + MinerStateSnapshot, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import ActionBar from "@/protoFleet/features/fleetManagement/components/ActionBar"; +import MinerActionsMenu from "@/protoFleet/features/fleetManagement/components/MinerActionsMenu"; +import { useSetActionBarVisible } from "@/protoFleet/store"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import { type SelectionMode } from "@/shared/components/List"; + +interface MinerListActionBarProps { + selectedMiners: string[]; + onClearSelection?: () => void; + onSelectAll?: () => void; + onSelectNone?: () => void; + selectionMode: SelectionMode; + totalCount?: number; + currentFilter?: MinerListFilter; + currentSort?: SortConfig; + miners?: Record; + minerIds?: string[]; + onRefetchMiners?: () => void; + onWorkerNameUpdated?: (deviceIdentifier: string, workerName: string) => void; +} + +const MinerListActionBar = ({ + selectedMiners, + onClearSelection, + onSelectAll, + onSelectNone, + selectionMode, + totalCount, + currentFilter, + currentSort, + miners, + minerIds, + onRefetchMiners, + onWorkerNameUpdated, +}: MinerListActionBarProps) => { + const setActionBarVisible = useSetActionBarVisible(); + const selectedMinersCountRef = useRef(selectedMiners.length); + + useEffect(() => { + selectedMinersCountRef.current = selectedMiners.length; + setActionBarVisible(selectedMiners.length > 0); + }, [selectedMiners.length, setActionBarVisible]); + + useEffect(() => { + return () => setActionBarVisible(false); + }, [setActionBarVisible]); + + const selectionControls = + onSelectAll || onSelectNone ? ( + <> + {onSelectAll ? ( + + ) : null} + {onSelectNone ? ( + + ) : null} + + ) : undefined; + + return ( + ( + { + setHidden(true); + setActionBarVisible(false); + }} + onActionComplete={() => { + setHidden(false); + setActionBarVisible(selectedMinersCountRef.current > 0); + }} + /> + )} + /> + ); +}; + +export default MinerListActionBar; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerMacAddress.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerMacAddress.tsx new file mode 100644 index 000000000..5d507b672 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerMacAddress.tsx @@ -0,0 +1,12 @@ +import { INACTIVE_PLACEHOLDER } from "./constants"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +type MinerMacAddressProps = { + miner: MinerStateSnapshot; +}; + +const MinerMacAddress = ({ miner }: MinerMacAddressProps) => { + return {miner.macAddress || INACTIVE_PLACEHOLDER}; +}; + +export default MinerMacAddress; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerMeasurement.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerMeasurement.tsx new file mode 100644 index 000000000..1c03a4380 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerMeasurement.tsx @@ -0,0 +1,43 @@ +import { INACTIVE_PLACEHOLDER } from "./constants"; +import { Measurement } from "@/protoFleet/api/generated/common/v1/measurement_pb"; +import SkeletonBar from "@/shared/components/SkeletonBar"; +import { getLatestMeasurementWithData } from "@/shared/utils/measurementUtils"; +import { getDisplayValue } from "@/shared/utils/stringUtils"; + +type MinerMeasurementProps = { + measurement: Measurement[] | undefined | null; + unit: string; + className?: string; +}; + +const MinerMeasurement = ({ measurement, unit, className }: MinerMeasurementProps) => { + // undefined = telemetry not loaded yet (show skeleton) + if (measurement === undefined) { + return ; + } + + // null = miner is inactive/offline (show placeholder) + if (measurement === null) { + return <>{INACTIVE_PLACEHOLDER}; + } + + // Empty array = empty cell for pool/auth required miners + if (measurement.length === 0) { + return null; + } + + const latestValue = getLatestMeasurementWithData(measurement)?.value; + + // Show value if available + if (latestValue !== undefined) { + return ( + <> + {getDisplayValue(latestValue)} {unit} + + ); + } + + return <>{INACTIVE_PLACEHOLDER}; +}; + +export default MinerMeasurement; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerModel.test.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerModel.test.tsx new file mode 100644 index 000000000..b0e79b0ef --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerModel.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import MinerModel from "./MinerModel"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +function createMockMiner(overrides: Partial = {}): MinerStateSnapshot { + return { + deviceIdentifier: "test-device", + name: "", + macAddress: "", + serialNumber: "", + powerUsage: [], + temperature: [], + hashrate: [], + efficiency: [], + ipAddress: "", + url: "", + deviceStatus: 0, + pairingStatus: 0, + model: "", + manufacturer: "", + temperatureStatus: 0, + firmwareVersion: "", + groupLabels: [], + rackLabel: "", + driverName: "", + workerName: "", + ...overrides, + } as MinerStateSnapshot; +} + +describe("MinerModel", () => { + it("renders the model name when available", () => { + const miner = createMockMiner({ model: "Proto Rig" }); + + render(); + + expect(screen.getByText("Proto Rig")).toBeInTheDocument(); + }); + + it("renders placeholder when model is empty string", () => { + const miner = createMockMiner({ model: "" }); + + render(); + + expect(screen.getByText("—")).toBeInTheDocument(); + }); + + it("renders Bitmain model names", () => { + const miner = createMockMiner({ model: "Antminer S19" }); + + render(); + + expect(screen.getByText("Antminer S19")).toBeInTheDocument(); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerModel.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerModel.tsx new file mode 100644 index 000000000..1d743088f --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerModel.tsx @@ -0,0 +1,12 @@ +import { INACTIVE_PLACEHOLDER } from "./constants"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +type MinerModelProps = { + miner: MinerStateSnapshot; +}; + +const MinerModel = ({ miner }: MinerModelProps) => { + return {miner.model || INACTIVE_PLACEHOLDER}; +}; + +export default MinerModel; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerName.test.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerName.test.tsx new file mode 100644 index 000000000..b6723ab0b --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerName.test.tsx @@ -0,0 +1,194 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import MinerName from "./MinerName"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { PairingStatus } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { DeviceStatus } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import * as useNeedsAttentionModule from "@/shared/hooks/useNeedsAttention"; + +vi.mock("@/shared/hooks/useNeedsAttention"); + +vi.mock("@/protoFleet/features/fleetManagement/components/MinerActionsMenu/SingleMinerActionsMenu", () => ({ + default: () =>
Actions Menu
, +})); + +function createMockMiner(overrides: Partial = {}): MinerStateSnapshot { + return { + deviceIdentifier: "test-device-id", + name: "Test Miner", + macAddress: "", + serialNumber: "", + powerUsage: [], + temperature: [], + hashrate: [], + efficiency: [], + ipAddress: "", + url: "", + deviceStatus: DeviceStatus.ONLINE, + pairingStatus: PairingStatus.PAIRED, + model: "", + manufacturer: "", + temperatureStatus: 0, + firmwareVersion: "", + groupLabels: [], + rackLabel: "", + driverName: "", + workerName: "", + ...overrides, + } as MinerStateSnapshot; +} + +describe("MinerName", () => { + const deviceIdentifier = "test-device-id"; + const minerName = "Test Miner"; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useNeedsAttentionModule.useNeedsAttention).mockReturnValue(false); + }); + + it("renders miner name with title attribute for tooltip", () => { + const miner = createMockMiner(); + + render(); + + const nameElement = screen.getByTitle(minerName); + expect(nameElement).toHaveTextContent(minerName); + }); + + it("falls back to device identifier when no custom name is set", () => { + const miner = createMockMiner({ name: "" }); + + render(); + + expect(screen.getByTitle(deviceIdentifier)).toBeInTheDocument(); + }); + + it("dims the miner name text when authentication is needed", () => { + const miner = createMockMiner({ pairingStatus: PairingStatus.AUTHENTICATION_NEEDED }); + + render(); + + expect(screen.getByTitle("Test Miner")).toHaveClass("opacity-50"); + }); + + it("does not dim the miner name text when paired", () => { + const miner = createMockMiner({ pairingStatus: PairingStatus.PAIRED }); + + render(); + + expect(screen.getByTitle("Test Miner")).not.toHaveClass("opacity-50"); + }); + + it("hides alert icon when authentication is required", () => { + vi.mocked(useNeedsAttentionModule.useNeedsAttention).mockReturnValue(true); + const miner = createMockMiner({ pairingStatus: PairingStatus.AUTHENTICATION_NEEDED }); + + render(); + + expect(screen.queryByRole("button", { name: /view issues/i })).not.toBeInTheDocument(); + }); + + it("hides alert icon when no attention is needed", () => { + vi.mocked(useNeedsAttentionModule.useNeedsAttention).mockReturnValue(false); + const miner = createMockMiner(); + + render(); + + expect(screen.queryByRole("button", { name: /view issues/i })).not.toBeInTheDocument(); + }); + + it("propagates click to row handler for navigation", async () => { + const user = userEvent.setup(); + const rowClickHandler = vi.fn(); + const miner = createMockMiner(); + + render( + + + + + + + +
+ + + +
, + ); + + await user.click(screen.getByTitle(minerName)); + + expect(rowClickHandler).toHaveBeenCalledTimes(1); + const checkbox = screen.getByTestId("row-checkbox") as HTMLInputElement; + expect(checkbox.checked).toBe(false); + }); + + it("lets click propagate when checkbox is disabled (for row navigation)", async () => { + const user = userEvent.setup(); + const rowClickHandler = vi.fn(); + const miner = createMockMiner(); + + render( + + + + + + + +
+ + + +
, + ); + + await user.click(screen.getByTitle(minerName)); + + expect(rowClickHandler).toHaveBeenCalledTimes(1); + }); + + it("calls onOpenStatusFlow when the alert icon is clicked", async () => { + const user = userEvent.setup(); + const onOpenStatusFlow = vi.fn(); + vi.mocked(useNeedsAttentionModule.useNeedsAttention).mockReturnValue(true); + const miner = createMockMiner(); + + render(); + + await user.click(screen.getByRole("button", { name: /view issues/i })); + + expect(onOpenStatusFlow).toHaveBeenCalledWith(deviceIdentifier); + }); + + it("shows spinner when action is loading", () => { + const miner = createMockMiner(); + + const { container } = render(); + + expect(container.querySelector(".animate-spin")).toBeInTheDocument(); + }); + + it("hides spinner when no action is loading", () => { + const miner = createMockMiner(); + + const { container } = render( + , + ); + + expect(container.querySelector(".animate-spin")).not.toBeInTheDocument(); + }); + + it("shows spinner instead of alert icon when action is loading", () => { + vi.mocked(useNeedsAttentionModule.useNeedsAttention).mockReturnValue(true); + const miner = createMockMiner(); + + const { container } = render(); + + expect(container.querySelector(".animate-spin")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /view issues/i })).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerName.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerName.tsx new file mode 100644 index 000000000..d62475092 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerName.tsx @@ -0,0 +1,75 @@ +import clsx from "clsx"; +import type { ErrorMessage } from "@/protoFleet/api/generated/errors/v1/errors_pb"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { PairingStatus } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { DeviceStatus } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import SingleMinerActionsMenu from "@/protoFleet/features/fleetManagement/components/MinerActionsMenu/SingleMinerActionsMenu"; +import { Alert } from "@/shared/assets/icons"; +import ProgressCircular from "@/shared/components/ProgressCircular"; +import { useNeedsAttention } from "@/shared/hooks/useNeedsAttention"; + +type MinerNameProps = { + miner: MinerStateSnapshot; + errors: ErrorMessage[]; + isActionLoading: boolean; + onOpenStatusFlow: (deviceIdentifier: string) => void; + miners?: Record; + onRefetchMiners?: () => void; + onWorkerNameUpdated?: (deviceIdentifier: string, workerName: string) => void; +}; + +const MinerName = ({ + miner, + errors, + isActionLoading, + onOpenStatusFlow, + miners, + onRefetchMiners, + onWorkerNameUpdated, +}: MinerNameProps) => { + const deviceIdentifier = miner.deviceIdentifier; + const name = miner.name || deviceIdentifier; + const deviceStatus = miner.deviceStatus; + + const needsAuthentication = miner.pairingStatus === PairingStatus.AUTHENTICATION_NEEDED; + const needsMiningPool = deviceStatus === DeviceStatus.NEEDS_MINING_POOL; + const hasFirmwareStatus = deviceStatus === DeviceStatus.UPDATING || deviceStatus === DeviceStatus.REBOOT_REQUIRED; + const needsAttention = useNeedsAttention(needsAuthentication, needsMiningPool, errors, false, hasFirmwareStatus); + + return ( +
+
+ {name} +
+
+ {isActionLoading ? ( + + ) : ( + needsAttention && + !needsAuthentication && ( + + ) + )} + +
+
+ ); +}; + +export default MinerName; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerPowerUsage.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerPowerUsage.tsx new file mode 100644 index 000000000..5fa43ac62 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerPowerUsage.tsx @@ -0,0 +1,22 @@ +import MinerMeasurement from "./MinerMeasurement"; +import UnsupportedMetric from "./UnsupportedMetric"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { getMinerMeasurement } from "@/protoFleet/features/fleetManagement/utils/getMinerMeasurement"; + +type MinerPowerUsageProps = { + miner: MinerStateSnapshot; +}; + +const MinerPowerUsage = ({ miner }: MinerPowerUsageProps) => { + const powerUsage = getMinerMeasurement(miner, (m) => m.powerUsage); + + // Check if miner doesn't support power usage reporting or capability is not available yet + const powerUsageReported = miner?.capabilities?.telemetry?.powerUsageReported; + if (!powerUsageReported) { + return ; + } + + return ; +}; + +export default MinerPowerUsage; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerStatus.test.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerStatus.test.tsx new file mode 100644 index 000000000..be088f4c1 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerStatus.test.tsx @@ -0,0 +1,431 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import MinerStatus from "./MinerStatus"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { DeviceStatus, PairingStatus } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { + deviceActions, + performanceActions, +} from "@/protoFleet/features/fleetManagement/components/MinerActionsMenu/constants"; +import type { BatchOperation } from "@/protoFleet/features/fleetManagement/hooks/useBatchOperations"; + +vi.mock("@/shared/hooks/useNeedsAttention", () => ({ + useNeedsAttention: vi.fn(() => false), +})); + +vi.mock("@/shared/hooks/useStatusSummary", () => ({ + useMinerStatus: vi.fn(() => "Hashing"), +})); + +function createMockMiner(overrides: Partial = {}): MinerStateSnapshot { + return { + deviceIdentifier: "test-device", + name: "", + macAddress: "", + serialNumber: "", + powerUsage: [], + temperature: [], + hashrate: [], + efficiency: [], + ipAddress: "", + url: "", + deviceStatus: DeviceStatus.ONLINE, + pairingStatus: PairingStatus.PAIRED, + model: "", + manufacturer: "", + temperatureStatus: 0, + firmwareVersion: "", + groupLabels: [], + rackLabel: "", + driverName: "", + workerName: "", + ...overrides, + } as MinerStateSnapshot; +} + +function createBatch(overrides: Partial = {}): BatchOperation { + return { + batchIdentifier: "batch-123", + action: deviceActions.reboot, + deviceIdentifiers: ["test-device"], + startedAt: Date.now(), + status: "in_progress", + ...overrides, + }; +} + +describe("MinerStatus", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("Loading state display", () => { + it("should show loading state when device has active batch operation and hasn't reached expected status", () => { + const miner = createMockMiner({ deviceStatus: DeviceStatus.OFFLINE }); + + render( + , + ); + + expect(screen.getByText("Rebooting")).toBeInTheDocument(); + }); + + it("should show pool assignment loading state", () => { + const miner = createMockMiner({ + deviceStatus: DeviceStatus.NEEDS_MINING_POOL, + }); + + render( + , + ); + + expect(screen.getByText("Adding pools")).toBeInTheDocument(); + }); + + it("should show spinner during batch operation", () => { + const miner = createMockMiner({ deviceStatus: DeviceStatus.ONLINE }); + + const { container } = render( + , + ); + + expect(screen.getByText("Sleeping")).toBeInTheDocument(); + expect(container.querySelector(".animate-spin")).toBeInTheDocument(); + }); + + it("should prioritize loading state over normal status", async () => { + const { useMinerStatus } = await import("@/shared/hooks/useStatusSummary"); + vi.mocked(useMinerStatus).mockReturnValue("Hashing"); + + const miner = createMockMiner({ deviceStatus: DeviceStatus.ONLINE }); + + render( + , + ); + + expect(screen.getByText("Blinking LEDs")).toBeInTheDocument(); + expect(screen.queryByText("Hashing")).not.toBeInTheDocument(); + }); + + it("should show unpairing loading state during unpair batch operation", () => { + const miner = createMockMiner({ deviceStatus: DeviceStatus.ONLINE }); + + render( + , + ); + + expect(screen.getByText("Unpairing")).toBeInTheDocument(); + expect(screen.queryByText("Hashing")).not.toBeInTheDocument(); + }); + + it("should show manage power loading state during manage-power batch operation", () => { + const miner = createMockMiner({ deviceStatus: DeviceStatus.ONLINE }); + + const { container } = render( + , + ); + + expect(screen.getByText("Updating power")).toBeInTheDocument(); + expect(screen.queryByText("Hashing")).not.toBeInTheDocument(); + expect(container.querySelector(".animate-spin")).toBeInTheDocument(); + }); + + it("should show first batch when device has multiple active batches", () => { + const miner = createMockMiner({ deviceStatus: DeviceStatus.OFFLINE }); + + render( + , + ); + + expect(screen.getByText("Rebooting")).toBeInTheDocument(); + }); + }); + + describe("Normal status display", () => { + it("should show normal status when no batch operations", async () => { + const { useMinerStatus } = await import("@/shared/hooks/useStatusSummary"); + vi.mocked(useMinerStatus).mockReturnValue("Hashing"); + + const miner = createMockMiner({ deviceStatus: DeviceStatus.ONLINE }); + + render(); + + expect(screen.getByText("Hashing")).toBeInTheDocument(); + expect(screen.queryByText("Rebooting")).not.toBeInTheDocument(); + }); + + it("should show needs attention status when no batches", async () => { + const { useNeedsAttention } = await import("@/shared/hooks/useNeedsAttention"); + const { useMinerStatus } = await import("@/shared/hooks/useStatusSummary"); + vi.mocked(useNeedsAttention).mockReturnValue(true); + vi.mocked(useMinerStatus).mockReturnValue("Needs attention"); + + const miner = createMockMiner({ + pairingStatus: PairingStatus.AUTHENTICATION_NEEDED, + deviceStatus: DeviceStatus.ONLINE, + }); + + render(); + + expect(screen.getByText("Needs attention")).toBeInTheDocument(); + }); + }); + + describe("Status after pool assignment", () => { + it("should clear needs attention when pool assigned to device without errors", async () => { + const { useNeedsAttention } = await import("@/shared/hooks/useNeedsAttention"); + const { useMinerStatus } = await import("@/shared/hooks/useStatusSummary"); + + vi.mocked(useNeedsAttention).mockReturnValue(true); + vi.mocked(useMinerStatus).mockReturnValue("Needs attention"); + + const miner = createMockMiner({ + deviceStatus: DeviceStatus.NEEDS_MINING_POOL, + }); + + const { rerender } = render(); + expect(screen.getByText("Needs attention")).toBeInTheDocument(); + + // Optimistic update: status changes to ONLINE after pool assignment + vi.mocked(useNeedsAttention).mockReturnValue(false); + vi.mocked(useMinerStatus).mockReturnValue("Hashing"); + + const updatedMiner = createMockMiner({ + deviceStatus: DeviceStatus.ONLINE, + }); + rerender(); + expect(screen.getByText("Hashing")).toBeInTheDocument(); + expect(screen.queryByText("Needs attention")).not.toBeInTheDocument(); + }); + + it("should still show needs attention when pool assigned to device with hardware errors", async () => { + const { useNeedsAttention } = await import("@/shared/hooks/useNeedsAttention"); + const { useMinerStatus } = await import("@/shared/hooks/useStatusSummary"); + + vi.mocked(useNeedsAttention).mockReturnValue(true); + vi.mocked(useMinerStatus).mockReturnValue("Needs attention"); + + const miner = createMockMiner({ + deviceStatus: DeviceStatus.NEEDS_MINING_POOL, + }); + + const { rerender } = render(); + expect(screen.getByText("Needs attention")).toBeInTheDocument(); + + // Optimistic update: status changes to ERROR (has hardware errors) + vi.mocked(useNeedsAttention).mockReturnValue(true); + vi.mocked(useMinerStatus).mockReturnValue("Needs attention"); + + const updatedMiner = createMockMiner({ + deviceStatus: DeviceStatus.ERROR, + }); + rerender(); + expect(screen.getByText("Needs attention")).toBeInTheDocument(); + }); + }); + + describe("Loading state clears when expected status reached", () => { + it("should show 'Sleeping' when device reaches INACTIVE during shutdown batch", async () => { + const { useMinerStatus } = await import("@/shared/hooks/useStatusSummary"); + vi.mocked(useMinerStatus).mockReturnValue("Hashing"); + + const batch = createBatch({ action: deviceActions.shutdown }); + const miner = createMockMiner({ deviceStatus: DeviceStatus.ONLINE }); + + const { rerender } = render(); + + expect(screen.getByText("Sleeping")).toBeInTheDocument(); + + // Device reaches INACTIVE status + vi.mocked(useMinerStatus).mockReturnValue("Sleeping"); + const updatedMiner = createMockMiner({ + deviceStatus: DeviceStatus.INACTIVE, + }); + + rerender(); + + // Should now show actual "Sleeping" status (not loading) + expect(screen.getByText("Sleeping")).toBeInTheDocument(); + }); + + it("should show actual status when device reaches non-INACTIVE during wakeUp batch", async () => { + const { useMinerStatus } = await import("@/shared/hooks/useStatusSummary"); + vi.mocked(useMinerStatus).mockReturnValue("Sleeping"); + + const batch = createBatch({ action: deviceActions.wakeUp }); + const miner = createMockMiner({ deviceStatus: DeviceStatus.INACTIVE }); + + const { rerender } = render(); + + // Should show loading state initially + expect(screen.getByText("Waking")).toBeInTheDocument(); + + // Device reaches ONLINE status + vi.mocked(useMinerStatus).mockReturnValue("Hashing"); + const updatedMiner = createMockMiner({ + deviceStatus: DeviceStatus.ONLINE, + }); + + rerender(); + + // Should now show actual "Hashing" status + expect(screen.getByText("Hashing")).toBeInTheDocument(); + expect(screen.queryByText("Waking up")).not.toBeInTheDocument(); + }); + + it("should show loading during reboot until minimum 15 seconds elapsed", async () => { + const { useMinerStatus } = await import("@/shared/hooks/useStatusSummary"); + vi.mocked(useMinerStatus).mockReturnValue("Offline"); + + const now = Date.now(); + const batch = createBatch({ + action: deviceActions.reboot, + startedAt: now - 10000, + }); + const miner = createMockMiner({ deviceStatus: DeviceStatus.OFFLINE }); + + const { rerender } = render(); + + // Should show loading state (less than 15s elapsed) + expect(screen.getByText("Rebooting")).toBeInTheDocument(); + + // Device reaches ONLINE status after only 10s + vi.mocked(useMinerStatus).mockReturnValue("Hashing"); + const updatedMiner = createMockMiner({ + deviceStatus: DeviceStatus.ONLINE, + }); + + rerender(); + + // Should still show "Rebooting" loading state (< 15s elapsed) + expect(screen.getByText("Rebooting")).toBeInTheDocument(); + expect(screen.queryByText("Hashing")).not.toBeInTheDocument(); + + // Update batch to 16 seconds ago (> 15s minimum) + const olderBatch = createBatch({ + action: deviceActions.reboot, + startedAt: now - 16000, + }); + + rerender(); + + // Now should show actual "Hashing" status (> 15s elapsed and status is ONLINE) + expect(screen.getByText("Hashing")).toBeInTheDocument(); + expect(screen.queryByText("Rebooting")).not.toBeInTheDocument(); + }); + + it("should show actual status when device reaches non-NEEDS_MINING_POOL during pool assignment", async () => { + const { useMinerStatus } = await import("@/shared/hooks/useStatusSummary"); + vi.mocked(useMinerStatus).mockReturnValue("Needs attention"); + + const batch = createBatch({ action: "mining-pool" }); + const miner = createMockMiner({ + deviceStatus: DeviceStatus.NEEDS_MINING_POOL, + }); + + const { rerender } = render(); + + // Should show loading state initially + expect(screen.getByText("Adding pools")).toBeInTheDocument(); + + // Device reaches ONLINE status + vi.mocked(useMinerStatus).mockReturnValue("Hashing"); + const updatedMiner = createMockMiner({ + deviceStatus: DeviceStatus.ONLINE, + }); + + rerender(); + + // Should now show actual "Hashing" status + expect(screen.getByText("Hashing")).toBeInTheDocument(); + expect(screen.queryByText("Adding pools")).not.toBeInTheDocument(); + }); + + it("should continue showing loading when device hasn't reached expected status", () => { + const miner = createMockMiner({ deviceStatus: DeviceStatus.ONLINE }); + const batch = createBatch({ action: deviceActions.shutdown }); + + render(); + + expect(screen.getByText("Sleeping")).toBeInTheDocument(); + }); + }); + + describe("Click handling", () => { + it("should call onClick when clickable and loading state", () => { + const miner = createMockMiner({ deviceStatus: DeviceStatus.OFFLINE }); + const onClick = vi.fn(); + + render( + , + ); + + const element = screen.getByText("Rebooting"); + element.click(); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("should render as a button when clickable", () => { + const miner = createMockMiner({ deviceStatus: DeviceStatus.OFFLINE }); + const onClick = vi.fn(); + + const { container } = render( + , + ); + + const button = container.querySelector("button"); + expect(button).toBeTruthy(); + expect(button?.className).toContain("cursor-pointer"); + expect(button?.className).toContain("hover:underline"); + }); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerStatus.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerStatus.tsx new file mode 100644 index 000000000..fae926d3d --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerStatus.tsx @@ -0,0 +1,132 @@ +import { ReactNode, useMemo } from "react"; +import { statusColumnLoadingMessages } from "../MinerActionsMenu/constants"; +import type { ErrorMessage } from "@/protoFleet/api/generated/errors/v1/errors_pb"; +import { DeviceStatus, PairingStatus } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import type { BatchOperation } from "@/protoFleet/features/fleetManagement/hooks/useBatchOperations"; +import { isActionLoading } from "@/protoFleet/features/fleetManagement/utils/batchStatusCheck"; +import ProgressCircular from "@/shared/components/ProgressCircular"; +import SkeletonBar from "@/shared/components/SkeletonBar"; +import StatusCircle, { statuses } from "@/shared/components/StatusCircle"; +import { useNeedsAttention } from "@/shared/hooks/useNeedsAttention"; +import { useMinerStatus } from "@/shared/hooks/useStatusSummary"; + +type StatusWrapperProps = { + onClick?: () => void; + children: ReactNode; +}; + +const StatusWrapper = ({ onClick, children }: StatusWrapperProps) => { + if (onClick) { + return ( + + ); + } + return
{children}
; +}; + +type MinerStatusProps = { + miner: MinerStateSnapshot; + errors: ErrorMessage[]; + activeBatches: BatchOperation[]; + errorsLoaded: boolean; + onClick?: () => void; +}; + +const MinerStatus = ({ miner, errors, activeBatches, errorsLoaded, onClick }: MinerStatusProps) => { + const deviceStatusFromStore = miner.deviceStatus; + + // Compute status flags + const needsAuthentication = miner.pairingStatus === PairingStatus.AUTHENTICATION_NEEDED; + const isPaired = miner.pairingStatus === PairingStatus.PAIRED; + // Paired miners with UNSPECIFIED device_status (typically freshly paired, not yet polled) + // are treated as offline — this matches the Fleet Health dashboard and Offline filter. + const isOffline = + deviceStatusFromStore === DeviceStatus.OFFLINE || (deviceStatusFromStore === DeviceStatus.UNSPECIFIED && isPaired); + // When authentication is needed, we can't trust INACTIVE/MAINTENANCE status + // (could be sleeping OR showing as inactive because we can't authenticate) + const isSleeping = + (deviceStatusFromStore === DeviceStatus.INACTIVE || deviceStatusFromStore === DeviceStatus.MAINTENANCE) && + !needsAuthentication; + const needsMiningPool = deviceStatusFromStore === DeviceStatus.NEEDS_MINING_POOL; + const hasDeviceError = deviceStatusFromStore === DeviceStatus.ERROR; + const isUpdating = deviceStatusFromStore === DeviceStatus.UPDATING; + const isRebootRequired = deviceStatusFromStore === DeviceStatus.REBOOT_REQUIRED; + + const needsAttention = useNeedsAttention( + needsAuthentication, + needsMiningPool, + errors, + hasDeviceError, + isUpdating || isRebootRequired, + ); + + // Compute status (Hashing, Offline, Sleeping, or Needs attention) + const status = useMinerStatus(isOffline, isSleeping, needsAttention); + + // Determine StatusCircle visual indicator based on flags + // Priority: (offline | sleeping) > needs attention > normal + // Note: isSleeping is already filtered to exclude auth-needed devices + const circleStatus = useMemo(() => { + if (isOffline || isSleeping) { + return statuses.sleeping; + } + if (needsAttention) { + return statuses.error; + } + return statuses.normal; + }, [isOffline, isSleeping, needsAttention]); + + // Check for active batch operations FIRST (highest priority) + const activeBatch = activeBatches[0]; + const batchLoadingMessage = activeBatch ? statusColumnLoadingMessages[activeBatch.action] : null; + + if (isActionLoading(activeBatch, deviceStatusFromStore)) { + const content = ( + <> + + + {batchLoadingMessage} + + ); + + return {content}; + } + + // Firmware update states — show dedicated indicators + if (isUpdating) { + return ( + + + + Updating firmware + + ); + } + + if (isRebootRequired) { + return ( + + + Reboot required + + ); + } + + // While errors haven't loaded yet, devices that would default to "Hashing" + // might actually need attention once errors arrive — show shimmer instead + if (!errorsLoaded && status === "Hashing") { + return ; + } + + return ( + + + {status} + + ); +}; + +export default MinerStatus; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerStatusCell.test.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerStatusCell.test.tsx new file mode 100644 index 000000000..ac11a5ce8 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerStatusCell.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import MinerStatusCell from "./MinerStatusCell"; +import type { DeviceListItem } from "./types"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +vi.mock("./MinerStatus", () => ({ + default: ({ onClick }: { onClick: () => void }) => ( + + ), +})); + +function createMockDevice(overrides: Partial = {}): DeviceListItem { + return { + deviceIdentifier: "test-device-id", + miner: { + deviceIdentifier: "test-device-id", + name: "", + macAddress: "", + serialNumber: "", + powerUsage: [], + temperature: [], + hashrate: [], + efficiency: [], + ipAddress: "", + url: "", + deviceStatus: 0, + pairingStatus: 0, + model: "", + manufacturer: "", + temperatureStatus: 0, + firmwareVersion: "", + groupLabels: [], + rackLabel: "", + driverName: "", + workerName: "", + } as unknown as MinerStateSnapshot, + errors: [], + activeBatches: [], + ...overrides, + }; +} + +describe("MinerStatusCell", () => { + it("calls onOpenStatusFlow when status is clicked", async () => { + const user = userEvent.setup(); + const onOpenStatusFlow = vi.fn(); + + render(); + + await user.click(screen.getByTestId("miner-status")); + + expect(onOpenStatusFlow).toHaveBeenCalledTimes(1); + expect(onOpenStatusFlow).toHaveBeenCalledWith("test-device-id"); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerStatusCell.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerStatusCell.tsx new file mode 100644 index 000000000..f91a88b27 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerStatusCell.tsx @@ -0,0 +1,22 @@ +import MinerStatus from "./MinerStatus"; +import type { DeviceListItem } from "./types"; + +type MinerStatusCellProps = { + device: DeviceListItem; + errorsLoaded: boolean; + onOpenStatusFlow: (deviceIdentifier: string) => void; +}; + +const MinerStatusCell = ({ device, errorsLoaded, onOpenStatusFlow }: MinerStatusCellProps) => { + return ( + onOpenStatusFlow(device.deviceIdentifier)} + /> + ); +}; + +export default MinerStatusCell; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerTemperature.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerTemperature.tsx new file mode 100644 index 000000000..a4f0f1b34 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerTemperature.tsx @@ -0,0 +1,46 @@ +import { INACTIVE_PLACEHOLDER } from "./constants"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { getMinerMeasurement } from "@/protoFleet/features/fleetManagement/utils/getMinerMeasurement"; +import { useTemperatureUnit } from "@/protoFleet/store"; +import SkeletonBar from "@/shared/components/SkeletonBar"; +import { getLatestMeasurementWithData } from "@/shared/utils/measurementUtils"; +import { getDisplayValue } from "@/shared/utils/stringUtils"; +import { convertCtoF } from "@/shared/utils/utility"; + +type MinerTemperatureProps = { + miner: MinerStateSnapshot; +}; + +const MinerTemperature = ({ miner }: MinerTemperatureProps) => { + const temperature = getMinerMeasurement(miner, (m) => m.temperature); + const temperatureUnit = useTemperatureUnit(); + + if (temperature === undefined) { + return ; + } + + if (temperature === null) { + return <>{INACTIVE_PLACEHOLDER}; + } + + // Empty array = empty cell for pool/auth required miners + if (temperature.length === 0) { + return null; + } + + const latestValue = getLatestMeasurementWithData(temperature)?.value; + + if (latestValue === undefined) { + return <>{INACTIVE_PLACEHOLDER}; + } + + const displayValue = temperatureUnit === "F" ? convertCtoF(latestValue) : latestValue; + + return ( + <> + {getDisplayValue(displayValue)} °{temperatureUnit} + + ); +}; + +export default MinerTemperature; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerWorkerName.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerWorkerName.tsx new file mode 100644 index 000000000..15e9ccd13 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerWorkerName.tsx @@ -0,0 +1,14 @@ +import { INACTIVE_PLACEHOLDER } from "./constants"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +type MinerWorkerNameProps = { + miner: MinerStateSnapshot; +}; + +const MinerWorkerName = ({ miner }: MinerWorkerNameProps) => { + const normalizedWorkerName = miner.workerName?.trim() ?? ""; + + return {normalizedWorkerName || INACTIVE_PLACEHOLDER}; +}; + +export default MinerWorkerName; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/UnsupportedMetric.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/UnsupportedMetric.tsx new file mode 100644 index 000000000..c55aa01da --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/UnsupportedMetric.tsx @@ -0,0 +1,30 @@ +import { useFloatingPosition } from "@/shared/hooks/useFloatingPosition"; + +export interface UnsupportedMetricProps { + message: string; +} + +const UnsupportedMetric = ({ message }: UnsupportedMetricProps) => { + const { triggerRef, floatingStyle, isVisible, show, hide } = useFloatingPosition({ + placement: "top-center", + gap: 8, + }); + + return ( + <> + + N/A + + {isVisible && ( + + {message} + + )} + + ); +}; + +export default UnsupportedMetric; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/constants.ts b/client/src/protoFleet/features/fleetManagement/components/MinerList/constants.ts new file mode 100644 index 000000000..c9b65b1aa --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/constants.ts @@ -0,0 +1,59 @@ +import { ColTitles } from "@/shared/components/List/types"; +export { INACTIVE_PLACEHOLDER } from "@/shared/constants"; + +export const MINERS_PAGE_SIZE = 50; + +export const minerCols = { + name: "name", + workerName: "workerName", + model: "model", + macAddress: "macAddress", + ipAddress: "ipAddress", + status: "status", + issues: "issues", + hashrate: "hashrate", + efficiency: "efficiency", + powerUsage: "powerUsage", + temperature: "temperature", + firmware: "firmware", + groups: "groups", +} as const; + +export type MinerColumn = (typeof minerCols)[keyof typeof minerCols]; + +export const minerColTitles: ColTitles = { + name: "Name", + workerName: "Worker name", + model: "Model", + macAddress: "MAC address", + ipAddress: "IP address", + status: "Status", + issues: "Issues", + hashrate: "Hashrate", + efficiency: "Efficiency", + powerUsage: "Power", + temperature: "Temp", + firmware: "Firmware", + groups: "Groups", +}; + +export const deviceStatusFilterStates = { + hashing: "hashing", + offline: "offline", + sleeping: "sleeping", + needsAttention: "needsAttention", +}; + +export type DeviceStatusFilterState = (typeof deviceStatusFilterStates)[keyof typeof deviceStatusFilterStates]; + +export const minerTypes = { + protoRig: "proto", + bitmain: "bitmain", +}; + +export const componentIssues = { + controlBoard: "control-board", + fans: "fans", + hashBoards: "hash-boards", + psu: "psu", +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/index.ts b/client/src/protoFleet/features/fleetManagement/components/MinerList/index.ts new file mode 100644 index 000000000..58f938b8a --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/index.ts @@ -0,0 +1,3 @@ +import MinerList from "./MinerList"; + +export default MinerList; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/minerColConfig.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/minerColConfig.tsx new file mode 100644 index 000000000..76e49f188 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/minerColConfig.tsx @@ -0,0 +1,114 @@ +import type { MutableRefObject } from "react"; +import { minerCols, type MinerColumn } from "./constants"; +import MinerEfficiency from "./MinerEfficiency"; +import MinerFirmware from "./MinerFirmware"; +import MinerGroups from "./MinerGroups"; +import MinerHashrate from "./MinerHashrate"; +import MinerIpAddress from "./MinerIpAddress"; +import MinerIssuesCell from "./MinerIssuesCell"; +import MinerMacAddress from "./MinerMacAddress"; +import MinerModel from "./MinerModel"; +import MinerName from "./MinerName"; +import MinerPowerUsage from "./MinerPowerUsage"; +import MinerStatusCell from "./MinerStatusCell"; +import MinerTemperature from "./MinerTemperature"; +import MinerWorkerName from "./MinerWorkerName"; +import { type DeviceListItem } from "./types"; +import { type DeviceSet } from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { isActionLoading } from "@/protoFleet/features/fleetManagement/utils/batchStatusCheck"; +import { type ColConfig } from "@/shared/components/List/types"; + +type CreateMinerColConfigParams = { + onOpenStatusFlow: (deviceIdentifier: string) => void; + availableGroups: DeviceSet[]; + errorsLoaded: boolean; + /** Ref to avoid recreating the column config on every miners change. Read at render time. */ + minersRef: MutableRefObject>; + /** Ref to avoid recreating the column config on every callback change. Read at render time. */ + onRefetchMinersRef: MutableRefObject<(() => void) | undefined>; + /** Ref to avoid recreating the column config on every callback change. Read at render time. */ + onWorkerNameUpdatedRef: MutableRefObject<((deviceIdentifier: string, workerName: string) => void) | undefined>; +}; + +const createMinerColConfig = ({ + onOpenStatusFlow, + availableGroups, + errorsLoaded, + minersRef, + onRefetchMinersRef, + onWorkerNameUpdatedRef, +}: CreateMinerColConfigParams): ColConfig => ({ + [minerCols.name]: { + component: (device: DeviceListItem) => { + const loading = isActionLoading(device.activeBatches[0], device.miner.deviceStatus); + + return ( + + ); + }, + width: "w-[208px]", + }, + [minerCols.workerName]: { + component: (device: DeviceListItem) => , + width: "w-[120px]", + }, + [minerCols.model]: { + component: (device: DeviceListItem) => , + width: "w-[176px]", + }, + [minerCols.macAddress]: { + component: (device: DeviceListItem) => , + width: "w-[160px]", + }, + [minerCols.ipAddress]: { + component: (device: DeviceListItem) => , + width: "w-24", + }, + [minerCols.status]: { + component: (device: DeviceListItem) => ( + + ), + width: "w-[200px]", + }, + [minerCols.issues]: { + component: (device: DeviceListItem) => ( + + ), + width: "w-[200px]", + }, + [minerCols.hashrate]: { + component: (device: DeviceListItem) => , + width: "w-[80px]", + }, + [minerCols.efficiency]: { + component: (device: DeviceListItem) => , + width: "w-[80px]", + }, + [minerCols.powerUsage]: { + component: (device: DeviceListItem) => , + width: "w-[80px]", + }, + [minerCols.temperature]: { + component: (device: DeviceListItem) => , + width: "w-[80px]", + }, + [minerCols.firmware]: { + component: (device: DeviceListItem) => , + width: "w-[120px]", + }, + [minerCols.groups]: { + component: (device: DeviceListItem) => , + width: "w-[160px]", + }, +}); + +export default createMinerColConfig; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/minerTableColumnPreferences.test.ts b/client/src/protoFleet/features/fleetManagement/components/MinerList/minerTableColumnPreferences.test.ts new file mode 100644 index 000000000..293c28978 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/minerTableColumnPreferences.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { minerCols } from "./constants"; +import { + buildActiveMinerColumns, + configurableMinerColumns, + normalizeMinerTableColumnPreferences, + reorderMinerTableColumns, + updateMinerTableColumnVisibility, +} from "./minerTableColumnPreferences"; + +describe("minerTableColumnPreferences", () => { + it("normalizes persisted preferences, drops invalid entries, and appends missing columns", () => { + const normalized = normalizeMinerTableColumnPreferences({ + columns: [ + { id: minerCols.model, visible: false }, + { id: minerCols.model, visible: true }, + { id: "unknown" as never, visible: true }, + ], + }); + + expect(normalized.columns[0]).toEqual({ id: minerCols.model, visible: false }); + expect(normalized.columns).toHaveLength(configurableMinerColumns.length); + expect(normalized.columns.map((column) => column.id)).toEqual([ + minerCols.model, + ...configurableMinerColumns.filter((columnId) => columnId !== minerCols.model), + ]); + }); + + it("builds active columns with name fixed first and only visible configurable columns", () => { + const preferences = updateMinerTableColumnVisibility( + normalizeMinerTableColumnPreferences({ + columns: configurableMinerColumns.map((columnId) => ({ id: columnId, visible: true })), + }), + minerCols.macAddress, + false, + ); + + expect(buildActiveMinerColumns(preferences)).toEqual([ + minerCols.name, + ...configurableMinerColumns.filter((columnId) => columnId !== minerCols.macAddress), + ]); + }); + + it("reorders configurable columns without moving the fixed name column", () => { + const reordered = reorderMinerTableColumns( + normalizeMinerTableColumnPreferences({ + columns: configurableMinerColumns.map((columnId) => ({ id: columnId, visible: true })), + }), + minerCols.workerName, + minerCols.groups, + ); + + expect(buildActiveMinerColumns(reordered).slice(0, 4)).toEqual([ + minerCols.name, + minerCols.workerName, + minerCols.groups, + minerCols.model, + ]); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/minerTableColumnPreferences.ts b/client/src/protoFleet/features/fleetManagement/components/MinerList/minerTableColumnPreferences.ts new file mode 100644 index 000000000..898967c1d --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/minerTableColumnPreferences.ts @@ -0,0 +1,113 @@ +import { minerCols, type MinerColumn } from "./constants"; + +export const configurableMinerColumns = [ + minerCols.groups, + minerCols.model, + minerCols.macAddress, + minerCols.ipAddress, + minerCols.status, + minerCols.issues, + minerCols.hashrate, + minerCols.efficiency, + minerCols.powerUsage, + minerCols.temperature, + minerCols.firmware, + minerCols.workerName, +] as const; + +export type ConfigurableMinerColumn = (typeof configurableMinerColumns)[number]; + +export type MinerTableColumnPreference = { + id: ConfigurableMinerColumn; + visible: boolean; +}; + +export type MinerTableColumnPreferences = { + columns: MinerTableColumnPreference[]; +}; + +const STORAGE_KEY_PREFIX = "proto-fleet-miner-table-columns"; + +const isConfigurableMinerColumn = (value: unknown): value is ConfigurableMinerColumn => + configurableMinerColumns.includes(value as ConfigurableMinerColumn); + +export const createDefaultMinerTableColumnPreferences = (): MinerTableColumnPreferences => ({ + columns: configurableMinerColumns.map((id) => ({ id, visible: true })), +}); + +export const normalizeMinerTableColumnPreferences = ( + preferences?: Partial | null, +): MinerTableColumnPreferences => { + const columns: MinerTableColumnPreference[] = []; + const seenIds = new Set(); + + for (const column of preferences?.columns ?? []) { + if (!column || !isConfigurableMinerColumn(column.id) || seenIds.has(column.id)) { + continue; + } + + seenIds.add(column.id); + columns.push({ + id: column.id, + visible: column.visible !== false, + }); + } + + for (const id of configurableMinerColumns) { + if (seenIds.has(id)) { + continue; + } + + columns.push({ + id, + visible: true, + }); + } + + return { columns }; +}; + +export const areMinerTableColumnPreferencesDefault = (preferences: MinerTableColumnPreferences): boolean => { + const normalizedPreferences = normalizeMinerTableColumnPreferences(preferences); + + return normalizedPreferences.columns.every( + (column, index) => column.id === configurableMinerColumns[index] && column.visible, + ); +}; + +export const buildActiveMinerColumns = (preferences: MinerTableColumnPreferences): MinerColumn[] => [ + minerCols.name, + ...normalizeMinerTableColumnPreferences(preferences) + .columns.filter((column) => column.visible) + .map((column) => column.id), +]; + +export const reorderMinerTableColumns = ( + preferences: MinerTableColumnPreferences, + activeId: ConfigurableMinerColumn, + overId: ConfigurableMinerColumn, +): MinerTableColumnPreferences => { + const oldIndex = preferences.columns.findIndex((column) => column.id === activeId); + const newIndex = preferences.columns.findIndex((column) => column.id === overId); + + if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) { + return preferences; + } + + const columns = [...preferences.columns]; + const [movedColumn] = columns.splice(oldIndex, 1); + columns.splice(newIndex, 0, movedColumn); + + return { columns }; +}; + +export const updateMinerTableColumnVisibility = ( + preferences: MinerTableColumnPreferences, + columnId: ConfigurableMinerColumn, + visible: boolean, +): MinerTableColumnPreferences => ({ + columns: preferences.columns.map((column) => (column.id === columnId ? { ...column, visible } : column)), +}); + +export const getMinerTableColumnPreferencesStorageKey = (username: string): string => + `${STORAGE_KEY_PREFIX}:${username || "anonymous"}`; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/sortConfig.ts b/client/src/protoFleet/features/fleetManagement/components/MinerList/sortConfig.ts new file mode 100644 index 000000000..5075cfa90 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/sortConfig.ts @@ -0,0 +1,42 @@ +import { minerCols, type MinerColumn } from "./constants"; + +import { SortField } from "@/protoFleet/api/generated/common/v1/sort_pb"; +import { SORT_ASC, SORT_DESC, type SortDirection } from "@/shared/components/List/types"; + +type SortColumnConfig = { + field: SortField; + defaultDirection: SortDirection; +}; + +/** Single source of truth for sortable column configuration. */ +const SORT_CONFIG: Partial> = { + [minerCols.name]: { field: SortField.NAME, defaultDirection: SORT_ASC }, + [minerCols.workerName]: { field: SortField.WORKER_NAME, defaultDirection: SORT_ASC }, + [minerCols.model]: { field: SortField.MODEL, defaultDirection: SORT_ASC }, + [minerCols.macAddress]: { field: SortField.MAC_ADDRESS, defaultDirection: SORT_ASC }, + [minerCols.ipAddress]: { field: SortField.IP_ADDRESS, defaultDirection: SORT_ASC }, + [minerCols.hashrate]: { field: SortField.HASHRATE, defaultDirection: SORT_DESC }, + [minerCols.efficiency]: { field: SortField.EFFICIENCY, defaultDirection: SORT_DESC }, + [minerCols.powerUsage]: { field: SortField.POWER, defaultDirection: SORT_DESC }, + [minerCols.temperature]: { field: SortField.TEMPERATURE, defaultDirection: SORT_DESC }, + [minerCols.firmware]: { field: SortField.FIRMWARE, defaultDirection: SORT_ASC }, +}; + +/** Columns that support sorting. */ +export const SORTABLE_COLUMNS = Object.keys(SORT_CONFIG) as MinerColumn[]; + +/** Gets the SortField for a column, or undefined if not sortable. */ +export function getSortField(column: MinerColumn): SortField | undefined { + return SORT_CONFIG[column]?.field; +} + +/** Gets the column for a SortField, or undefined if not found. Used when parsing sort from URL. */ +export function getColumnForSortField(field: SortField): MinerColumn | undefined { + const entry = Object.entries(SORT_CONFIG).find(([, config]) => config.field === field); + return entry?.[0] as MinerColumn | undefined; +} + +/** Gets the default sort direction for a column. */ +export function getDefaultSortDirection(column: MinerColumn): SortDirection { + return SORT_CONFIG[column]?.defaultDirection ?? SORT_ASC; +} diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/stories/MinerList.stories.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/stories/MinerList.stories.tsx new file mode 100644 index 000000000..c0de6bf10 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/stories/MinerList.stories.tsx @@ -0,0 +1,106 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { action } from "storybook/actions"; +import MinerListComponent from "../MinerList"; +import type { ErrorMessage } from "@/protoFleet/api/generated/errors/v1/errors_pb"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { miners } from "@/protoFleet/features/fleetManagement/components/MinerList/stories/mocks"; +import { + allIssueMiners, + allStatusMiners, + errorMessages, +} from "@/protoFleet/features/fleetManagement/components/MinerList/stories/statusMocks"; +import { Toaster as ToasterComponent } from "@/shared/features/toaster"; + +const meta: Meta = { + title: "Proto Fleet/MinerList", + component: MinerListComponent, +}; + +export default meta; +type Story = StoryObj; + +const buildMinersRecord = (minerList: MinerStateSnapshot[]): Record => + Object.fromEntries(minerList.map((m) => [m.deviceIdentifier, m])); + +const buildErrorsByDevice = ( + minerList: MinerStateSnapshot[], + errors: ErrorMessage[], +): Record => { + const byDevice: Record = {}; + for (const m of minerList) { + byDevice[m.deviceIdentifier] = []; + } + for (const error of errors) { + if (error.deviceIdentifier && byDevice[error.deviceIdentifier]) { + byDevice[error.deviceIdentifier].push(error); + } + } + return byDevice; +}; + +// Helper component to render MinerList with props derived from mock data +const MinerListWrapper = ({ minerList }: { minerList: MinerStateSnapshot[] }) => { + const minerIds = minerList.map((miner) => miner.deviceIdentifier); + const minersRecord = buildMinersRecord(minerList); + const errorsByDevice = buildErrorsByDevice(minerList, errorMessages); + + return ( +
+
+ +
+ []} + onAddMiners={action("onAddMiners")} + /> +
+ ); +}; + +// ============================================================================ +// Consolidated Story with All States and Issues +// ============================================================================ + +export const AllStatusesAndIssuesMinerList: Story = { + render: () => { + const allMiners = [...allStatusMiners, ...allIssueMiners]; + return ( +
+
+

All Statuses and Issues

+ +
+
+ ); + }, +}; + +// ============================================================================ +// Other Examples +// ============================================================================ + +export const OperationalMinerList: Story = { + render: () => , +}; + +export const EmptyMinerList: Story = { + render: () => ( +
+ []} + totalMiners={0} + onAddMiners={action("onAddMiners")} + /> +
+ ), +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/stories/mocks.ts b/client/src/protoFleet/features/fleetManagement/components/MinerList/stories/mocks.ts new file mode 100644 index 000000000..26265bfa6 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/stories/mocks.ts @@ -0,0 +1,234 @@ +import { type Measurement } from "@/protoFleet/api/generated/common/v1/measurement_pb"; +import { + DeviceStatus, + type MinerStateSnapshot, + PairingStatus, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { TemperatureStatus } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; + +export const miners: MinerStateSnapshot[] = [ + { + $typeName: "fleetmanagement.v1.MinerStateSnapshot", + deviceIdentifier: "uuid:123456789", + serialNumber: "123456789", + name: "C1-M01", + ipAddress: "0123456789", + macAddress: "0a:04:8a:54:fa:9f", + url: "https://0123456789:8080", + pairingStatus: PairingStatus.PAIRED, + model: "S19 Pro", + manufacturer: "Bitmain", + workerName: "worker-01", + driverName: "antminer", + hashrate: [ + { + timestamp: { seconds: BigInt(1641024000), nanos: 0 }, + value: 189, + } as Measurement, + { + timestamp: { seconds: BigInt(1641110400), nanos: 0 }, + value: 194, + } as Measurement, + { + timestamp: { seconds: BigInt(1641196800), nanos: 0 }, + value: 190, + } as Measurement, + { + timestamp: { seconds: BigInt(1641283200), nanos: 0 }, + value: 213.2, + } as Measurement, + ], + efficiency: [ + { + timestamp: { seconds: BigInt(2), nanos: 0 }, + value: 15.5, + } as Measurement, + ], + powerUsage: [ + { + timestamp: { seconds: BigInt(2), nanos: 0 }, + value: 3.5, + } as Measurement, + ], + temperature: [ + { + timestamp: { seconds: BigInt(2), nanos: 0 }, + value: 65.5, + } as Measurement, + ], + deviceStatus: DeviceStatus.ONLINE, + temperatureStatus: TemperatureStatus.OK, + firmwareVersion: "2.0.0", + groupLabels: [], + rackLabel: "", + rackPosition: "", + }, + { + $typeName: "fleetmanagement.v1.MinerStateSnapshot", + deviceIdentifier: "uuid:1234567890", + serialNumber: "123456780", + name: "C1-M02", + macAddress: "0b:04:8a:54:fa:9f", + ipAddress: "0123456781", + url: "https://0123456781:8080", + pairingStatus: PairingStatus.PAIRED, + model: "S19 Pro", + manufacturer: "Bitmain", + workerName: "worker-02", + driverName: "antminer", + hashrate: [ + { + timestamp: { seconds: BigInt(1641024000), nanos: 0 }, + value: 160, + } as Measurement, + { + timestamp: { seconds: BigInt(1641110400), nanos: 0 }, + value: 163, + } as Measurement, + { + timestamp: { seconds: BigInt(1641196800), nanos: 0 }, + value: 165, + } as Measurement, + { + timestamp: { seconds: BigInt(1641283200), nanos: 0 }, + value: 150.8, + } as Measurement, + ], + efficiency: [ + { + timestamp: { seconds: BigInt(2), nanos: 0 }, + value: 15.5, + } as Measurement, + ], + powerUsage: [ + { + timestamp: { seconds: BigInt(2), nanos: 0 }, + value: 3.5, + } as Measurement, + ], + temperature: [ + { + timestamp: { seconds: BigInt(2), nanos: 0 }, + value: 65.5, + } as Measurement, + ], + deviceStatus: DeviceStatus.ONLINE, + temperatureStatus: TemperatureStatus.OK, + firmwareVersion: "2.0.0", + groupLabels: [], + rackLabel: "", + rackPosition: "", + }, + { + $typeName: "fleetmanagement.v1.MinerStateSnapshot", + deviceIdentifier: "uuid:123456781", + serialNumber: "123456781", + ipAddress: "172.27.244.166", + name: "C1-M03", + macAddress: "0c:04:8a:54:fa:9f", + url: "https://172.27.244.166:8080", + pairingStatus: PairingStatus.PAIRED, + model: "S19 Pro", + manufacturer: "Bitmain", + workerName: "worker-03", + driverName: "antminer", + hashrate: [ + { + timestamp: { seconds: BigInt(1641024000), nanos: 0 }, + value: 184, + } as Measurement, + { + timestamp: { seconds: BigInt(1641110400), nanos: 0 }, + value: 196, + } as Measurement, + { + timestamp: { seconds: BigInt(1641196800), nanos: 0 }, + value: 194, + } as Measurement, + { + timestamp: { seconds: BigInt(1641283200), nanos: 0 }, + value: 187, + } as Measurement, + ], + efficiency: [ + { + timestamp: { seconds: BigInt(2), nanos: 0 }, + value: 15.5, + } as Measurement, + ], + powerUsage: [ + { + timestamp: { seconds: BigInt(2), nanos: 0 }, + value: 3.5, + } as Measurement, + ], + temperature: [ + { + timestamp: { seconds: BigInt(2), nanos: 0 }, + value: 65.5, + } as Measurement, + ], + deviceStatus: DeviceStatus.ONLINE, + temperatureStatus: TemperatureStatus.OK, + firmwareVersion: "2.0.0", + groupLabels: [], + rackLabel: "", + rackPosition: "", + }, + { + $typeName: "fleetmanagement.v1.MinerStateSnapshot", + deviceIdentifier: "uuid:123456782", + serialNumber: "123456782", + ipAddress: "172.27.244.166", + name: "C1-M04", + macAddress: "0e:04:8a:54:fa:9f", + url: "https://172.27.244.166:8080", + pairingStatus: PairingStatus.PAIRED, + model: "S19 Pro", + manufacturer: "Bitmain", + workerName: "worker-04", + driverName: "antminer", + hashrate: [ + { + timestamp: { seconds: BigInt(1641024000), nanos: 0 }, + value: 184, + } as Measurement, + { + timestamp: { seconds: BigInt(1641110400), nanos: 0 }, + value: 196, + } as Measurement, + { + timestamp: { seconds: BigInt(1641196800), nanos: 0 }, + value: 194, + } as Measurement, + { + timestamp: { seconds: BigInt(1641283200), nanos: 0 }, + value: 152.3, + } as Measurement, + ], + efficiency: [ + { + timestamp: { seconds: BigInt(2), nanos: 0 }, + value: 15.5, + } as Measurement, + ], + powerUsage: [ + { + timestamp: { seconds: BigInt(2), nanos: 0 }, + value: 3.5, + } as Measurement, + ], + temperature: [ + { + timestamp: { seconds: BigInt(2), nanos: 0 }, + value: 65.5, + } as Measurement, + ], + deviceStatus: DeviceStatus.ONLINE, + temperatureStatus: TemperatureStatus.OK, + firmwareVersion: "2.0.0", + groupLabels: [], + rackLabel: "", + rackPosition: "", + }, +]; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/stories/statusMocks.ts b/client/src/protoFleet/features/fleetManagement/components/MinerList/stories/statusMocks.ts new file mode 100644 index 000000000..0008b881f --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/stories/statusMocks.ts @@ -0,0 +1,476 @@ +/** + * Comprehensive mock data showing all possible miner statuses and issues + * for Storybook visual verification + */ + +import { create } from "@bufbuild/protobuf"; +import { + type MinerCapabilities, + MinerCapabilitiesSchema, + type TelemetryCapabilities, + TelemetryCapabilitiesSchema, +} from "@/protoFleet/api/generated/capabilities/v1/capabilities_pb"; +import { type Measurement } from "@/protoFleet/api/generated/common/v1/measurement_pb"; +import { + ComponentType, + ErrorMessageSchema, + MinerError, + Severity, +} from "@/protoFleet/api/generated/errors/v1/errors_pb"; +import { type ErrorMessage } from "@/protoFleet/api/generated/errors/v1/errors_pb"; +import { + DeviceStatus, + type MinerStateSnapshot, + PairingStatus, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { TemperatureStatus } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; + +// Shared capabilities - all miners support all telemetry metrics +const baseTelemetryCapabilities: TelemetryCapabilities = create(TelemetryCapabilitiesSchema, { + realtimeTelemetrySupported: true, + historicalDataSupported: true, + hashrateReported: true, + powerUsageReported: true, + temperatureReported: true, + fanSpeedReported: true, + efficiencyReported: true, + uptimeReported: true, + errorCountReported: true, + minerStatusReported: true, + poolStatsReported: true, + perChipStatsReported: false, + perBoardStatsReported: false, + psuStatsReported: false, +}); + +const baseCapabilities: MinerCapabilities = create(MinerCapabilitiesSchema, { + manufacturer: "Bitmain", + telemetry: baseTelemetryCapabilities, +}); + +// Shared measurement data +const baseMeasurements = { + hashrate: [ + { + timestamp: { seconds: BigInt(1641283200), nanos: 0 }, + value: 100.0, + } as Measurement, + ], + efficiency: [ + { + timestamp: { seconds: BigInt(2), nanos: 0 }, + value: 15.5, + } as Measurement, + ], + powerUsage: [ + { + timestamp: { seconds: BigInt(2), nanos: 0 }, + value: 3.5, + } as Measurement, + ], + temperature: [ + { + timestamp: { seconds: BigInt(2), nanos: 0 }, + value: 65.5, + } as Measurement, + ], + workerName: "worker-base", + groupLabels: [] as string[], + rackLabel: "", + rackPosition: "", +}; + +// ============================================================================ +// Status Examples +// ============================================================================ + +export const hashingMiner: MinerStateSnapshot = { + $typeName: "fleetmanagement.v1.MinerStateSnapshot", + deviceIdentifier: "uuid:status-hashing", + serialNumber: "SN-HASHING", + name: "Hashing Miner", + ipAddress: "192.168.1.101", + macAddress: "0a:00:00:00:00:01", + url: "https://192.168.1.101:8080", + pairingStatus: PairingStatus.PAIRED, + model: "S19 Pro", + manufacturer: "Bitmain", + driverName: "antminer", + ...baseMeasurements, + deviceStatus: DeviceStatus.ONLINE, + temperatureStatus: TemperatureStatus.OK, + firmwareVersion: "2.0.0", + capabilities: baseCapabilities, +}; + +export const offlineMiner: MinerStateSnapshot = { + $typeName: "fleetmanagement.v1.MinerStateSnapshot", + deviceIdentifier: "uuid:status-offline", + serialNumber: "SN-OFFLINE", + name: "Offline Miner", + ipAddress: "192.168.1.102", + macAddress: "0a:00:00:00:00:02", + url: "https://192.168.1.102:8080", + pairingStatus: PairingStatus.PAIRED, + model: "S19 Pro", + manufacturer: "Bitmain", + workerName: "worker-offline", + driverName: "antminer", + hashrate: [], + efficiency: [], + powerUsage: [], + temperature: [], + deviceStatus: DeviceStatus.OFFLINE, + temperatureStatus: TemperatureStatus.OK, + firmwareVersion: "2.0.0", + capabilities: baseCapabilities, + groupLabels: [], + rackLabel: "", + rackPosition: "", +}; + +export const sleepingMiner: MinerStateSnapshot = { + $typeName: "fleetmanagement.v1.MinerStateSnapshot", + deviceIdentifier: "uuid:status-sleeping", + serialNumber: "SN-SLEEPING", + name: "Sleeping Miner", + ipAddress: "192.168.1.103", + macAddress: "0a:00:00:00:00:03", + url: "https://192.168.1.103:8080", + pairingStatus: PairingStatus.PAIRED, + model: "S19 Pro", + manufacturer: "Bitmain", + workerName: "worker-sleeping", + driverName: "antminer", + hashrate: [ + { + timestamp: { seconds: BigInt(1641283200), nanos: 0 }, + value: 0, + } as Measurement, + ], + efficiency: [], + powerUsage: [], + temperature: baseMeasurements.temperature, + deviceStatus: DeviceStatus.INACTIVE, + temperatureStatus: TemperatureStatus.OK, + firmwareVersion: "2.0.0", + capabilities: baseCapabilities, + groupLabels: [], + rackLabel: "", + rackPosition: "", +}; + +// ============================================================================ +// Issue Examples - Simple Issues +// ============================================================================ + +export const authRequiredMiner: MinerStateSnapshot = { + $typeName: "fleetmanagement.v1.MinerStateSnapshot", + deviceIdentifier: "uuid:issue-auth", + serialNumber: "SN-AUTH", + name: "Auth Required", + ipAddress: "192.168.1.110", + macAddress: "0a:00:00:00:00:10", + url: "https://192.168.1.110:8080", + pairingStatus: PairingStatus.AUTHENTICATION_NEEDED, + model: "S19 Pro", + manufacturer: "Bitmain", + workerName: "worker-auth", + driverName: "antminer", + hashrate: [], + efficiency: [], + powerUsage: [], + temperature: [], + deviceStatus: DeviceStatus.ERROR, + temperatureStatus: TemperatureStatus.OK, + firmwareVersion: "2.0.0", + capabilities: baseCapabilities, + groupLabels: [], + rackLabel: "", + rackPosition: "", +}; + +export const poolRequiredMiner: MinerStateSnapshot = { + $typeName: "fleetmanagement.v1.MinerStateSnapshot", + deviceIdentifier: "uuid:issue-pool", + serialNumber: "SN-POOL", + name: "Pool Required", + ipAddress: "192.168.1.111", + macAddress: "0a:00:00:00:00:11", + url: "https://192.168.1.111:8080", + pairingStatus: PairingStatus.PAIRED, + model: "S19 Pro", + manufacturer: "Bitmain", + driverName: "antminer", + ...baseMeasurements, + deviceStatus: DeviceStatus.NEEDS_MINING_POOL, + temperatureStatus: TemperatureStatus.OK, + firmwareVersion: "2.0.0", + capabilities: baseCapabilities, +}; + +export const controlBoardFailureMiner: MinerStateSnapshot = { + $typeName: "fleetmanagement.v1.MinerStateSnapshot", + deviceIdentifier: "uuid:issue-controlboard", + serialNumber: "SN-CTRLBOARD", + name: "Control Board Issue", + ipAddress: "192.168.1.112", + macAddress: "0a:00:00:00:00:12", + url: "https://192.168.1.112:8080", + pairingStatus: PairingStatus.PAIRED, + model: "S19 Pro", + manufacturer: "Bitmain", + driverName: "antminer", + ...baseMeasurements, + deviceStatus: DeviceStatus.ERROR, + temperatureStatus: TemperatureStatus.OK, + firmwareVersion: "2.0.0", + capabilities: baseCapabilities, +}; + +export const hashboardFailureMiner: MinerStateSnapshot = { + $typeName: "fleetmanagement.v1.MinerStateSnapshot", + deviceIdentifier: "uuid:issue-hashboard", + serialNumber: "SN-HASHBOARD", + name: "Hashboard Issue", + ipAddress: "192.168.1.113", + macAddress: "0a:00:00:00:00:13", + url: "https://192.168.1.113:8080", + pairingStatus: PairingStatus.PAIRED, + model: "S19 Pro", + manufacturer: "Bitmain", + driverName: "antminer", + ...baseMeasurements, + deviceStatus: DeviceStatus.ERROR, + temperatureStatus: TemperatureStatus.OK, + firmwareVersion: "2.0.0", + capabilities: baseCapabilities, +}; + +export const psuFailureMiner: MinerStateSnapshot = { + $typeName: "fleetmanagement.v1.MinerStateSnapshot", + deviceIdentifier: "uuid:issue-psu", + serialNumber: "SN-PSU", + name: "PSU Issue", + ipAddress: "192.168.1.114", + macAddress: "0a:00:00:00:00:14", + url: "https://192.168.1.114:8080", + pairingStatus: PairingStatus.PAIRED, + model: "S19 Pro", + manufacturer: "Bitmain", + driverName: "antminer", + ...baseMeasurements, + deviceStatus: DeviceStatus.ERROR, + temperatureStatus: TemperatureStatus.OK, + firmwareVersion: "2.0.0", + capabilities: baseCapabilities, +}; + +export const fanFailureMiner: MinerStateSnapshot = { + $typeName: "fleetmanagement.v1.MinerStateSnapshot", + deviceIdentifier: "uuid:issue-fan", + serialNumber: "SN-FAN", + name: "Fan Issue", + ipAddress: "192.168.1.115", + macAddress: "0a:00:00:00:00:15", + url: "https://192.168.1.115:8080", + pairingStatus: PairingStatus.PAIRED, + model: "S19 Pro", + manufacturer: "Bitmain", + driverName: "antminer", + ...baseMeasurements, + deviceStatus: DeviceStatus.ERROR, + temperatureStatus: TemperatureStatus.OK, + firmwareVersion: "2.0.0", + capabilities: baseCapabilities, +}; + +// ============================================================================ +// Issue Examples - Multiple Failures +// ============================================================================ + +export const multipleHashboardFailuresMiner: MinerStateSnapshot = { + $typeName: "fleetmanagement.v1.MinerStateSnapshot", + deviceIdentifier: "uuid:issue-multiple-hashboards", + serialNumber: "SN-MULTI-HB", + name: "Multiple Hashboards", + ipAddress: "192.168.1.120", + macAddress: "0a:00:00:00:00:20", + url: "https://192.168.1.120:8080", + pairingStatus: PairingStatus.PAIRED, + model: "S19 Pro", + manufacturer: "Bitmain", + driverName: "antminer", + ...baseMeasurements, + deviceStatus: DeviceStatus.ERROR, + temperatureStatus: TemperatureStatus.OK, + firmwareVersion: "2.0.0", + capabilities: baseCapabilities, +}; + +export const multipleComponentFailuresMiner: MinerStateSnapshot = { + $typeName: "fleetmanagement.v1.MinerStateSnapshot", + deviceIdentifier: "uuid:issue-multiple-components", + serialNumber: "SN-MULTI-COMP", + name: "Multiple Components", + ipAddress: "192.168.1.121", + macAddress: "0a:00:00:00:00:21", + url: "https://192.168.1.121:8080", + pairingStatus: PairingStatus.PAIRED, + model: "S19 Pro", + manufacturer: "Bitmain", + driverName: "antminer", + ...baseMeasurements, + deviceStatus: DeviceStatus.ERROR, + temperatureStatus: TemperatureStatus.OK, + firmwareVersion: "2.0.0", + capabilities: baseCapabilities, +}; + +// ============================================================================ +// Error Messages (to be added to normalized error store) +// ============================================================================ + +export const errorMessages: ErrorMessage[] = [ + // Control board error + create(ErrorMessageSchema, { + errorId: "error-controlboard-1", + deviceIdentifier: "uuid:issue-controlboard", + componentType: ComponentType.CONTROL_BOARD, + componentId: "1", + summary: "Control board failure detected", + canonicalError: MinerError.UNSPECIFIED, + severity: Severity.MAJOR, + causeSummary: "", + recommendedAction: "", + impact: "", + vendorAttributes: {}, + }), + // Single hashboard error + create(ErrorMessageSchema, { + errorId: "error-hashboard-1", + deviceIdentifier: "uuid:issue-hashboard", + componentType: ComponentType.HASH_BOARD, + componentId: "1", + summary: "Hashboard 1 failure", + canonicalError: MinerError.UNSPECIFIED, + severity: Severity.MAJOR, + causeSummary: "", + recommendedAction: "", + impact: "", + vendorAttributes: {}, + }), + // PSU error + create(ErrorMessageSchema, { + errorId: "error-psu-1", + deviceIdentifier: "uuid:issue-psu", + componentType: ComponentType.PSU, + componentId: "1", + summary: "PSU 1 failure", + canonicalError: MinerError.UNSPECIFIED, + severity: Severity.MAJOR, + causeSummary: "", + recommendedAction: "", + impact: "", + vendorAttributes: {}, + }), + // Fan error + create(ErrorMessageSchema, { + errorId: "error-fan-1", + deviceIdentifier: "uuid:issue-fan", + componentType: ComponentType.FAN, + componentId: "1", + summary: "Fan 1 failure", + canonicalError: MinerError.UNSPECIFIED, + severity: Severity.MAJOR, + causeSummary: "", + recommendedAction: "", + impact: "", + vendorAttributes: {}, + }), + // Multiple hashboard errors (same component type) + create(ErrorMessageSchema, { + errorId: "error-hashboard-2", + deviceIdentifier: "uuid:issue-multiple-hashboards", + componentType: ComponentType.HASH_BOARD, + componentId: "1", + summary: "Hashboard 1 failure", + canonicalError: MinerError.UNSPECIFIED, + severity: Severity.MAJOR, + causeSummary: "", + recommendedAction: "", + impact: "", + vendorAttributes: {}, + }), + create(ErrorMessageSchema, { + errorId: "error-hashboard-3", + deviceIdentifier: "uuid:issue-multiple-hashboards", + componentType: ComponentType.HASH_BOARD, + componentId: "2", + summary: "Hashboard 2 failure", + canonicalError: MinerError.UNSPECIFIED, + severity: Severity.MAJOR, + causeSummary: "", + recommendedAction: "", + impact: "", + vendorAttributes: {}, + }), + // Multiple component type errors + create(ErrorMessageSchema, { + errorId: "error-multi-hashboard", + deviceIdentifier: "uuid:issue-multiple-components", + componentType: ComponentType.HASH_BOARD, + componentId: "1", + summary: "Hashboard 1 failure", + canonicalError: MinerError.UNSPECIFIED, + severity: Severity.MAJOR, + causeSummary: "", + recommendedAction: "", + impact: "", + vendorAttributes: {}, + }), + create(ErrorMessageSchema, { + errorId: "error-multi-fan", + deviceIdentifier: "uuid:issue-multiple-components", + componentType: ComponentType.FAN, + componentId: "1", + summary: "Fan 1 failure", + canonicalError: MinerError.UNSPECIFIED, + severity: Severity.MAJOR, + causeSummary: "", + recommendedAction: "", + impact: "", + vendorAttributes: {}, + }), + create(ErrorMessageSchema, { + errorId: "error-multi-psu", + deviceIdentifier: "uuid:issue-multiple-components", + componentType: ComponentType.PSU, + componentId: "1", + summary: "PSU 1 failure", + canonicalError: MinerError.UNSPECIFIED, + severity: Severity.MAJOR, + causeSummary: "", + recommendedAction: "", + impact: "", + vendorAttributes: {}, + }), +]; + +// All status miners +export const allStatusMiners: MinerStateSnapshot[] = [hashingMiner, offlineMiner, sleepingMiner]; + +// All issue miners +export const allIssueMiners: MinerStateSnapshot[] = [ + authRequiredMiner, + poolRequiredMiner, + controlBoardFailureMiner, + hashboardFailureMiner, + psuFailureMiner, + fanFailureMiner, + multipleHashboardFailuresMiner, + multipleComponentFailuresMiner, +]; + +// All miners combined +export const allMiners: MinerStateSnapshot[] = [...allStatusMiners, ...allIssueMiners]; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/types.ts b/client/src/protoFleet/features/fleetManagement/components/MinerList/types.ts new file mode 100644 index 000000000..cb82cd6d2 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/types.ts @@ -0,0 +1,11 @@ +import type { ErrorMessage } from "@/protoFleet/api/generated/errors/v1/errors_pb"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import type { BatchOperation } from "@/protoFleet/features/fleetManagement/hooks/useBatchOperations"; + +// DeviceListItem represents a device in the miner list with all data needed for rendering +export type DeviceListItem = { + deviceIdentifier: string; + miner: MinerStateSnapshot; + errors: ErrorMessage[]; + activeBatches: BatchOperation[]; +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/useMinerTableColumnPreferences.ts b/client/src/protoFleet/features/fleetManagement/components/MinerList/useMinerTableColumnPreferences.ts new file mode 100644 index 000000000..3b9b22112 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/useMinerTableColumnPreferences.ts @@ -0,0 +1,75 @@ +import { useCallback, useMemo, useState } from "react"; +import { + areMinerTableColumnPreferencesDefault, + createDefaultMinerTableColumnPreferences, + getMinerTableColumnPreferencesStorageKey, + type MinerTableColumnPreferences, + normalizeMinerTableColumnPreferences, +} from "./minerTableColumnPreferences"; + +const readMinerTableColumnPreferences = (storageKey: string): MinerTableColumnPreferences => { + try { + const rawValue = localStorage.getItem(storageKey); + if (!rawValue) { + return createDefaultMinerTableColumnPreferences(); + } + + return normalizeMinerTableColumnPreferences(JSON.parse(rawValue)); + } catch { + return createDefaultMinerTableColumnPreferences(); + } +}; + +const persistMinerTableColumnPreferences = (storageKey: string, preferences: MinerTableColumnPreferences): void => { + const normalizedPreferences = normalizeMinerTableColumnPreferences(preferences); + + try { + if (areMinerTableColumnPreferencesDefault(normalizedPreferences)) { + localStorage.removeItem(storageKey); + return; + } + + localStorage.setItem(storageKey, JSON.stringify(normalizedPreferences)); + } catch { + // Ignore persistence failures and continue using in-memory state. + } +}; + +type PreferenceState = { + storageKey: string; + preferences: MinerTableColumnPreferences; +}; + +const useMinerTableColumnPreferences = (username: string) => { + const storageKey = useMemo(() => getMinerTableColumnPreferencesStorageKey(username), [username]); + const [preferenceState, setPreferenceState] = useState(() => ({ + storageKey, + preferences: readMinerTableColumnPreferences(storageKey), + })); + const preferences = useMemo( + () => + preferenceState.storageKey === storageKey + ? preferenceState.preferences + : readMinerTableColumnPreferences(storageKey), + [preferenceState.preferences, preferenceState.storageKey, storageKey], + ); + + const setPreferences = useCallback( + (nextPreferences: MinerTableColumnPreferences) => { + const normalizedPreferences = normalizeMinerTableColumnPreferences(nextPreferences); + setPreferenceState({ + storageKey, + preferences: normalizedPreferences, + }); + persistMinerTableColumnPreferences(storageKey, normalizedPreferences); + }, + [storageKey], + ); + + return { + preferences, + setPreferences, + }; +}; + +export default useMinerTableColumnPreferences; diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/utils.test.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/utils.test.tsx new file mode 100644 index 000000000..cd1194098 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/utils.test.tsx @@ -0,0 +1,55 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { getComponentIcon } from "./utils"; +import { ComponentType as ErrorComponentType } from "@/protoFleet/api/generated/errors/v1/errors_pb"; + +describe("getComponentIcon", () => { + it("should return Alert icon for UNSPECIFIED component type", () => { + const icon = getComponentIcon(ErrorComponentType.UNSPECIFIED); + const { container } = render(
{icon}
); + expect(container.querySelector("svg")).toBeInTheDocument(); + }); + + it("should return LightningAlt icon for PSU component type", () => { + const icon = getComponentIcon(ErrorComponentType.PSU); + const { container } = render(
{icon}
); + expect(container.querySelector("svg")).toBeInTheDocument(); + }); + + it("should return Hashboard icon for HASH_BOARD component type", () => { + const icon = getComponentIcon(ErrorComponentType.HASH_BOARD); + const { container } = render(
{icon}
); + expect(container.querySelector("svg")).toBeInTheDocument(); + }); + + it("should return Fan icon for FAN component type", () => { + const icon = getComponentIcon(ErrorComponentType.FAN); + const { container } = render(
{icon}
); + expect(container.querySelector("svg")).toBeInTheDocument(); + }); + + it("should return ControlBoard icon for CONTROL_BOARD component type", () => { + const icon = getComponentIcon(ErrorComponentType.CONTROL_BOARD); + const { container } = render(
{icon}
); + expect(container.querySelector("svg")).toBeInTheDocument(); + }); + + it("should return Alert icon for EEPROM component type", () => { + const icon = getComponentIcon(ErrorComponentType.EEPROM); + const { container } = render(
{icon}
); + expect(container.querySelector("svg")).toBeInTheDocument(); + }); + + it("should return Alert icon for IO_MODULE component type", () => { + const icon = getComponentIcon(ErrorComponentType.IO_MODULE); + const { container } = render(
{icon}
); + expect(container.querySelector("svg")).toBeInTheDocument(); + }); + + it("should return Alert icon as fallback for unmapped component types", () => { + // Test with an invalid component type to ensure fallback works + const icon = getComponentIcon(999 as ErrorComponentType); + const { container } = render(
{icon}
); + expect(container.querySelector("svg")).toBeInTheDocument(); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/utils.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/utils.tsx new file mode 100644 index 000000000..537cd7c1c --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/utils.tsx @@ -0,0 +1,22 @@ +import { ReactNode } from "react"; +import { ComponentType as ErrorComponentType } from "@/protoFleet/api/generated/errors/v1/errors_pb"; +import { Alert, ControlBoard, Fan, Hashboard, LightningAlt } from "@/shared/assets/icons"; + +/** + * Map error component type to icon + * @param componentType - The error component type from the API + * @returns React node representing the component icon + */ +export function getComponentIcon(componentType: ErrorComponentType): ReactNode { + const componentIconMap: Record = { + [ErrorComponentType.UNSPECIFIED]: , + [ErrorComponentType.PSU]: , + [ErrorComponentType.HASH_BOARD]: , + [ErrorComponentType.FAN]: , + [ErrorComponentType.CONTROL_BOARD]: , + [ErrorComponentType.EEPROM]: , + [ErrorComponentType.IO_MODULE]: , + }; + + return componentIconMap[componentType] ?? ; +} diff --git a/client/src/protoFleet/features/fleetManagement/hooks/useBatchOperations.test.ts b/client/src/protoFleet/features/fleetManagement/hooks/useBatchOperations.test.ts new file mode 100644 index 000000000..a12e43ff5 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/hooks/useBatchOperations.test.ts @@ -0,0 +1,327 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useBatchOperations } from "./useBatchOperations"; +import { + deviceActions, + settingsActions, +} from "@/protoFleet/features/fleetManagement/components/MinerActionsMenu/constants"; +import { useFleetStore } from "@/protoFleet/store/useFleetStore"; + +// Reset the Zustand store's batch state between tests +beforeEach(() => { + const state = useFleetStore.getState(); + // Clear all batch state by completing any existing batches + for (const batchId of Object.keys(state.batch.byBatchId)) { + state.batch.completeBatchOperation(batchId); + } +}); + +describe("useBatchOperations", () => { + describe("startBatchOperation", () => { + it("should add a new batch operation", () => { + const { result } = renderHook(() => useBatchOperations()); + + act(() => { + result.current.startBatchOperation({ + batchIdentifier: "batch-123", + action: deviceActions.reboot, + deviceIdentifiers: ["device-1", "device-2"], + }); + }); + + const batches = result.current.getActiveBatches("device-1"); + expect(batches).toHaveLength(1); + expect(batches[0].batchIdentifier).toBe("batch-123"); + expect(batches[0].action).toBe(deviceActions.reboot); + expect(batches[0].deviceIdentifiers).toEqual(["device-1", "device-2"]); + expect(batches[0].status).toBe("in_progress"); + expect(batches[0].startedAt).toBeGreaterThan(0); + }); + + it("should add batch ID to all devices", () => { + const { result } = renderHook(() => useBatchOperations()); + const deviceIdentifiers = ["device-1", "device-2", "device-3"]; + + act(() => { + result.current.startBatchOperation({ + batchIdentifier: "batch-123", + action: deviceActions.reboot, + deviceIdentifiers, + }); + }); + + deviceIdentifiers.forEach((deviceId) => { + const batches = result.current.getActiveBatches(deviceId); + expect(batches).toHaveLength(1); + expect(batches[0].batchIdentifier).toBe("batch-123"); + }); + }); + + it("should support multiple batches for the same device", () => { + const { result } = renderHook(() => useBatchOperations()); + + act(() => { + result.current.startBatchOperation({ + batchIdentifier: "batch-1", + action: deviceActions.reboot, + deviceIdentifiers: ["device-1"], + }); + result.current.startBatchOperation({ + batchIdentifier: "batch-2", + action: settingsActions.miningPool, + deviceIdentifiers: ["device-1"], + }); + }); + + const batches = result.current.getActiveBatches("device-1"); + expect(batches).toHaveLength(2); + }); + + it("should not add duplicate batch IDs to the same device", () => { + const { result } = renderHook(() => useBatchOperations()); + + act(() => { + result.current.startBatchOperation({ + batchIdentifier: "batch-1", + action: deviceActions.reboot, + deviceIdentifiers: ["device-1"], + }); + // Same batch again + result.current.startBatchOperation({ + batchIdentifier: "batch-1", + action: deviceActions.reboot, + deviceIdentifiers: ["device-1"], + }); + }); + + const batches = result.current.getActiveBatches("device-1"); + expect(batches).toHaveLength(1); + }); + }); + + describe("completeBatchOperation", () => { + it("should remove batch and clean up device indexes", () => { + const { result } = renderHook(() => useBatchOperations()); + + act(() => { + result.current.startBatchOperation({ + batchIdentifier: "batch-123", + action: deviceActions.reboot, + deviceIdentifiers: ["device-1", "device-2"], + }); + }); + + act(() => { + result.current.completeBatchOperation("batch-123"); + }); + + expect(result.current.getActiveBatches("device-1")).toHaveLength(0); + expect(result.current.getActiveBatches("device-2")).toHaveLength(0); + }); + + it("should handle completing non-existent batch gracefully", () => { + const { result } = renderHook(() => useBatchOperations()); + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + act(() => { + result.current.completeBatchOperation("non-existent"); + }); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("non-existent")); + consoleSpy.mockRestore(); + }); + + it("should preserve other batches for the same device", () => { + const { result } = renderHook(() => useBatchOperations()); + + act(() => { + result.current.startBatchOperation({ + batchIdentifier: "batch-1", + action: deviceActions.reboot, + deviceIdentifiers: ["device-1"], + }); + result.current.startBatchOperation({ + batchIdentifier: "batch-2", + action: settingsActions.miningPool, + deviceIdentifiers: ["device-1"], + }); + }); + + act(() => { + result.current.completeBatchOperation("batch-1"); + }); + + const batches = result.current.getActiveBatches("device-1"); + expect(batches).toHaveLength(1); + expect(batches[0].batchIdentifier).toBe("batch-2"); + }); + }); + + describe("removeDevicesFromBatch", () => { + it("should remove specified devices from batch", () => { + const { result } = renderHook(() => useBatchOperations()); + + act(() => { + result.current.startBatchOperation({ + batchIdentifier: "batch-123", + action: deviceActions.reboot, + deviceIdentifiers: ["device-1", "device-2", "device-3"], + }); + }); + + act(() => { + result.current.removeDevicesFromBatch("batch-123", ["device-1"]); + }); + + expect(result.current.getActiveBatches("device-1")).toHaveLength(0); + expect(result.current.getActiveBatches("device-2")).toHaveLength(1); + expect(result.current.getActiveBatches("device-3")).toHaveLength(1); + }); + + it("should delete batch entirely if all devices are removed", () => { + const { result } = renderHook(() => useBatchOperations()); + + act(() => { + result.current.startBatchOperation({ + batchIdentifier: "batch-123", + action: deviceActions.reboot, + deviceIdentifiers: ["device-1"], + }); + }); + + act(() => { + result.current.removeDevicesFromBatch("batch-123", ["device-1"]); + }); + + expect(result.current.getActiveBatches("device-1")).toHaveLength(0); + expect(result.current.getAllBatches()).toHaveLength(0); + }); + + it("should preserve other batches for the same device", () => { + const { result } = renderHook(() => useBatchOperations()); + + act(() => { + result.current.startBatchOperation({ + batchIdentifier: "batch-1", + action: deviceActions.reboot, + deviceIdentifiers: ["device-1"], + }); + result.current.startBatchOperation({ + batchIdentifier: "batch-2", + action: settingsActions.miningPool, + deviceIdentifiers: ["device-1"], + }); + }); + + act(() => { + result.current.removeDevicesFromBatch("batch-1", ["device-1"]); + }); + + const batches = result.current.getActiveBatches("device-1"); + expect(batches).toHaveLength(1); + expect(batches[0].batchIdentifier).toBe("batch-2"); + }); + }); + + describe("cleanupStaleBatches", () => { + it("should remove batches older than 5 minutes", () => { + const { result } = renderHook(() => useBatchOperations()); + + // Mock Date.now to control time + const originalNow = Date.now; + const startTime = 1000000; + Date.now = () => startTime; + + act(() => { + result.current.startBatchOperation({ + batchIdentifier: "old-batch", + action: deviceActions.reboot, + deviceIdentifiers: ["device-1"], + }); + }); + + // Advance time past stale threshold (5 minutes) + Date.now = () => startTime + 5 * 60 * 1000 + 1; + + act(() => { + result.current.startBatchOperation({ + batchIdentifier: "new-batch", + action: deviceActions.reboot, + deviceIdentifiers: ["device-2"], + }); + }); + + act(() => { + result.current.cleanupStaleBatches(); + }); + + expect(result.current.getActiveBatches("device-1")).toHaveLength(0); + expect(result.current.getActiveBatches("device-2")).toHaveLength(1); + + Date.now = originalNow; + }); + }); + + describe("getActiveBatches", () => { + it("should return empty array for device with no batches", () => { + const { result } = renderHook(() => useBatchOperations()); + expect(result.current.getActiveBatches("unknown-device")).toEqual([]); + }); + + it("should return all active batches for a device", () => { + const { result } = renderHook(() => useBatchOperations()); + + act(() => { + result.current.startBatchOperation({ + batchIdentifier: "batch-1", + action: deviceActions.reboot, + deviceIdentifiers: ["device-1"], + }); + result.current.startBatchOperation({ + batchIdentifier: "batch-2", + action: settingsActions.miningPool, + deviceIdentifiers: ["device-1"], + }); + }); + + const batches = result.current.getActiveBatches("device-1"); + expect(batches).toHaveLength(2); + expect(batches.map((b) => b.batchIdentifier)).toEqual(["batch-1", "batch-2"]); + }); + }); + + describe("integration: full lifecycle", () => { + it("should handle start -> partial remove -> complete", () => { + const { result } = renderHook(() => useBatchOperations()); + + act(() => { + result.current.startBatchOperation({ + batchIdentifier: "batch-123", + action: deviceActions.reboot, + deviceIdentifiers: ["device-1", "device-2", "device-3"], + }); + }); + + expect(result.current.getActiveBatches("device-1")).toHaveLength(1); + expect(result.current.getActiveBatches("device-2")).toHaveLength(1); + expect(result.current.getActiveBatches("device-3")).toHaveLength(1); + + act(() => { + result.current.removeDevicesFromBatch("batch-123", ["device-1"]); + }); + + expect(result.current.getActiveBatches("device-1")).toHaveLength(0); + expect(result.current.getActiveBatches("device-2")).toHaveLength(1); + expect(result.current.getActiveBatches("device-3")).toHaveLength(1); + + act(() => { + result.current.completeBatchOperation("batch-123"); + }); + + expect(result.current.getActiveBatches("device-1")).toHaveLength(0); + expect(result.current.getActiveBatches("device-2")).toHaveLength(0); + expect(result.current.getActiveBatches("device-3")).toHaveLength(0); + expect(result.current.getAllBatches()).toHaveLength(0); + }); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/hooks/useBatchOperations.ts b/client/src/protoFleet/features/fleetManagement/hooks/useBatchOperations.ts new file mode 100644 index 000000000..a42f353d6 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/hooks/useBatchOperations.ts @@ -0,0 +1,41 @@ +import { + getActiveBatches, + getAllBatches, + useBatchStateVersion, + useCleanupStaleBatches, + useCompleteBatchOperation, + useRemoveDevicesFromBatch, + useStartBatchOperation, +} from "@/protoFleet/store"; + +// Re-export types from the store slice for consumers +export type { BatchOperation, BatchOperationInput } from "@/protoFleet/store/slices/batchSlice"; + +/** + * Manages ephemeral batch operation state for the fleet page. + * Tracks in-progress operations (firmware updates, reboots, etc.) so + * MinerStatus can show an in-progress state while an action is running. + * + * State is stored in the Zustand batch slice so it survives route navigation + * (e.g., rebooting from Groups page then navigating to Miners page). + */ +export function useBatchOperations() { + const startBatchOperation = useStartBatchOperation(); + const completeBatchOperation = useCompleteBatchOperation(); + const removeDevicesFromBatch = useRemoveDevicesFromBatch(); + const cleanupStaleBatches = useCleanupStaleBatches(); + const batchStateVersion = useBatchStateVersion(); + + return { + startBatchOperation, + completeBatchOperation, + removeDevicesFromBatch, + cleanupStaleBatches, + /** Reads directly from the store — always returns fresh data. */ + getAllBatches, + /** Reads directly from the store — always returns fresh data. */ + getActiveBatches, + /** Monotonic counter that increments on every batch state mutation. Use as a memo dependency. */ + batchStateVersion, + }; +} diff --git a/client/src/protoFleet/features/fleetManagement/index.ts b/client/src/protoFleet/features/fleetManagement/index.ts new file mode 100644 index 000000000..1baedd954 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/index.ts @@ -0,0 +1,3 @@ +import Fleet from "./components/Fleet"; + +export { Fleet }; diff --git a/client/src/protoFleet/features/fleetManagement/types.ts b/client/src/protoFleet/features/fleetManagement/types.ts new file mode 100644 index 000000000..2df6003b9 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/types.ts @@ -0,0 +1,16 @@ +import { type StatusCircleStatus } from "@/shared/components/StatusCircle/constants"; + +export type MinerStatus = { + hashboard: StatusCircleStatus; + asic: StatusCircleStatus; + fans: StatusCircleStatus; + cb: StatusCircleStatus; + + // TODO: these will probably be derived from the above + hashing: boolean; + offline: boolean; + asleep: boolean; + broken: boolean; +}; + +export type MinerStatusKey = keyof MinerStatus; diff --git a/client/src/protoFleet/features/fleetManagement/utils/batchStatusCheck.test.ts b/client/src/protoFleet/features/fleetManagement/utils/batchStatusCheck.test.ts new file mode 100644 index 000000000..2b5b48009 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/utils/batchStatusCheck.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; +import { deviceActions, settingsActions } from "../components/MinerActionsMenu/constants"; +import { hasReachedExpectedStatus, isActionLoading } from "./batchStatusCheck"; +import { DeviceStatus } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import type { BatchOperation } from "@/protoFleet/features/fleetManagement/hooks/useBatchOperations"; + +function createBatch(overrides: Partial = {}): BatchOperation { + return { + batchIdentifier: "batch-123", + action: deviceActions.reboot, + deviceIdentifiers: ["device-1"], + startedAt: Date.now(), + status: "in_progress", + ...overrides, + }; +} + +describe("hasReachedExpectedStatus", () => { + describe("mining pool action", () => { + it("returns true when status is not NEEDS_MINING_POOL", () => { + expect(hasReachedExpectedStatus(settingsActions.miningPool, DeviceStatus.ONLINE)).toBe(true); + expect(hasReachedExpectedStatus(settingsActions.miningPool, DeviceStatus.OFFLINE)).toBe(true); + expect(hasReachedExpectedStatus(settingsActions.miningPool, DeviceStatus.INACTIVE)).toBe(true); + }); + + it("returns false when status is NEEDS_MINING_POOL", () => { + expect(hasReachedExpectedStatus(settingsActions.miningPool, DeviceStatus.NEEDS_MINING_POOL)).toBe(false); + }); + + it("returns false when status is undefined", () => { + expect(hasReachedExpectedStatus(settingsActions.miningPool, undefined)).toBe(false); + }); + }); + + describe("shutdown action", () => { + it("returns true when status is INACTIVE", () => { + expect(hasReachedExpectedStatus(deviceActions.shutdown, DeviceStatus.INACTIVE)).toBe(true); + }); + + it("returns false when status is not INACTIVE", () => { + expect(hasReachedExpectedStatus(deviceActions.shutdown, DeviceStatus.ONLINE)).toBe(false); + expect(hasReachedExpectedStatus(deviceActions.shutdown, DeviceStatus.OFFLINE)).toBe(false); + expect(hasReachedExpectedStatus(deviceActions.shutdown, DeviceStatus.UNSPECIFIED)).toBe(false); + }); + + it("returns false when status is undefined", () => { + expect(hasReachedExpectedStatus(deviceActions.shutdown, undefined)).toBe(false); + }); + }); + + describe("wakeUp action", () => { + it("returns true when status is not INACTIVE", () => { + expect(hasReachedExpectedStatus(deviceActions.wakeUp, DeviceStatus.ONLINE)).toBe(true); + expect(hasReachedExpectedStatus(deviceActions.wakeUp, DeviceStatus.OFFLINE)).toBe(true); + expect(hasReachedExpectedStatus(deviceActions.wakeUp, DeviceStatus.NEEDS_MINING_POOL)).toBe(true); + }); + + it("returns false when status is INACTIVE", () => { + expect(hasReachedExpectedStatus(deviceActions.wakeUp, DeviceStatus.INACTIVE)).toBe(false); + }); + + it("returns false when status is undefined", () => { + expect(hasReachedExpectedStatus(deviceActions.wakeUp, undefined)).toBe(false); + }); + }); + + describe("reboot action", () => { + it("returns false when less than 15 seconds have elapsed", () => { + const now = Date.now(); + const startedAt = now - 10000; // 10 seconds ago + + expect(hasReachedExpectedStatus(deviceActions.reboot, DeviceStatus.ONLINE, startedAt)).toBe(false); + expect(hasReachedExpectedStatus(deviceActions.reboot, DeviceStatus.OFFLINE, startedAt)).toBe(false); + expect(hasReachedExpectedStatus(deviceActions.reboot, DeviceStatus.INACTIVE, startedAt)).toBe(false); + }); + + it("returns true when 15+ seconds elapsed and status is not OFFLINE", () => { + const now = Date.now(); + const startedAt = now - 16000; // 16 seconds ago + + expect(hasReachedExpectedStatus(deviceActions.reboot, DeviceStatus.ONLINE, startedAt)).toBe(true); + expect(hasReachedExpectedStatus(deviceActions.reboot, DeviceStatus.INACTIVE, startedAt)).toBe(true); + expect(hasReachedExpectedStatus(deviceActions.reboot, DeviceStatus.NEEDS_MINING_POOL, startedAt)).toBe(true); + }); + + it("returns false when 15+ seconds elapsed but status is OFFLINE", () => { + const now = Date.now(); + const startedAt = now - 16000; // 16 seconds ago + + expect(hasReachedExpectedStatus(deviceActions.reboot, DeviceStatus.OFFLINE, startedAt)).toBe(false); + }); + + it("returns false when status is undefined", () => { + const now = Date.now(); + const startedAt = now - 16000; + + expect(hasReachedExpectedStatus(deviceActions.reboot, undefined, startedAt)).toBe(false); + }); + + it("returns false when no startedAt provided (defaults to 0 elapsed)", () => { + // Without startedAt, elapsed = 0, which is < 15000 + expect(hasReachedExpectedStatus(deviceActions.reboot, DeviceStatus.ONLINE)).toBe(false); + }); + }); + + describe("firmware update action", () => { + it("returns true when status is REBOOT_REQUIRED", () => { + expect(hasReachedExpectedStatus(deviceActions.firmwareUpdate, DeviceStatus.REBOOT_REQUIRED)).toBe(true); + }); + + it("returns false when status is UPDATING", () => { + expect(hasReachedExpectedStatus(deviceActions.firmwareUpdate, DeviceStatus.UPDATING)).toBe(false); + }); + + it("returns false when status is ONLINE", () => { + expect(hasReachedExpectedStatus(deviceActions.firmwareUpdate, DeviceStatus.ONLINE)).toBe(false); + }); + + it("returns false when status is undefined", () => { + expect(hasReachedExpectedStatus(deviceActions.firmwareUpdate, undefined)).toBe(false); + }); + }); + + describe("unknown action", () => { + it("returns false for unknown actions", () => { + expect(hasReachedExpectedStatus("unknown-action", DeviceStatus.ONLINE)).toBe(false); + expect(hasReachedExpectedStatus("unknown-action", DeviceStatus.INACTIVE)).toBe(false); + }); + }); +}); + +describe("isActionLoading", () => { + it("returns false when batch is undefined", () => { + expect(isActionLoading(undefined, DeviceStatus.ONLINE)).toBe(false); + }); + + it("returns false when action has no statusColumnLoadingMessages entry", () => { + const batch = createBatch({ action: deviceActions.downloadLogs }); + expect(isActionLoading(batch, DeviceStatus.ONLINE)).toBe(false); + }); + + it("returns false when device has reached expected status", () => { + const batch = createBatch({ action: deviceActions.shutdown }); + expect(isActionLoading(batch, DeviceStatus.INACTIVE)).toBe(false); + }); + + it("returns true when device has not reached expected status", () => { + const batch = createBatch({ action: deviceActions.shutdown }); + expect(isActionLoading(batch, DeviceStatus.ONLINE)).toBe(true); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/utils/batchStatusCheck.ts b/client/src/protoFleet/features/fleetManagement/utils/batchStatusCheck.ts new file mode 100644 index 000000000..5d474edbb --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/utils/batchStatusCheck.ts @@ -0,0 +1,55 @@ +import { deviceActions, settingsActions, statusColumnLoadingMessages } from "../components/MinerActionsMenu/constants"; +import { DeviceStatus } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import type { BatchOperation } from "@/protoFleet/features/fleetManagement/hooks/useBatchOperations"; + +/** + * Check if a device has reached the expected status for a given batch action. + * This logic is shared between status polling and UI display. + */ +export function hasReachedExpectedStatus( + action: string, + deviceStatus: DeviceStatus | undefined, + startedAt?: number, +): boolean { + if (deviceStatus === undefined) return false; + + // Check expected status based on action + if (action === settingsActions.miningPool) { + // Pool assignment: complete when no longer NEEDS_MINING_POOL + return deviceStatus !== DeviceStatus.NEEDS_MINING_POOL; + } else if (action === deviceActions.shutdown) { + // Sleep: complete when status is INACTIVE + return deviceStatus === DeviceStatus.INACTIVE; + } else if (action === deviceActions.wakeUp) { + // Wake up: complete when no longer INACTIVE + return deviceStatus !== DeviceStatus.INACTIVE; + } else if (action === deviceActions.reboot) { + // Reboot: transient operation (ONLINE → OFFLINE → ONLINE) + // Note: 15 seconds is a conservative minimum that works across all miner types: + // - Proto miners typically reboot in 10-12 seconds + // - Antminers can take 12-15 seconds depending on hardware + // This ensures the device has time to go offline and come back online + const minRebootDuration = 15000; // 15 seconds + const elapsed = startedAt ? Date.now() - startedAt : 0; + + if (elapsed < minRebootDuration) { + return false; // Too early, keep showing loading + } + + // After 15s, complete when device is no longer OFFLINE + return deviceStatus !== DeviceStatus.OFFLINE; + } else if (action === deviceActions.firmwareUpdate) { + return deviceStatus === DeviceStatus.REBOOT_REQUIRED; + } + + return false; +} + +/** + * Check if a batch action is actively loading (has a loading message and + * the device hasn't yet reached the expected status for that action). + */ +export function isActionLoading(batch: BatchOperation | undefined, deviceStatus: DeviceStatus | undefined): boolean { + if (!batch || !statusColumnLoadingMessages[batch.action]) return false; + return !hasReachedExpectedStatus(batch.action, deviceStatus, batch.startedAt); +} diff --git a/client/src/protoFleet/features/fleetManagement/utils/deviceSelector.test.ts b/client/src/protoFleet/features/fleetManagement/utils/deviceSelector.test.ts new file mode 100644 index 000000000..4faf00bda --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/utils/deviceSelector.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { createDeviceSelector } from "./deviceSelector"; + +describe("createDeviceSelector", () => { + describe("when selectionMode is 'all'", () => { + it("returns DeviceSelector with allDevices filter (no criteria)", () => { + const result = createDeviceSelector("all", ["device-1", "device-2"]); + + expect(result.selectionType.case).toBe("allDevices"); + if (result.selectionType.case === "allDevices") { + expect(result.selectionType.value).toBeDefined(); + expect(result.selectionType.value.deviceStatus).toEqual([]); + expect(result.selectionType.value.pairingStatus).toEqual([]); + } + }); + + it("ignores deviceIdentifiers when mode is 'all'", () => { + const result = createDeviceSelector("all", []); + + expect(result.selectionType.case).toBe("allDevices"); + if (result.selectionType.case === "allDevices") { + expect(result.selectionType.value).toBeDefined(); + } + }); + }); + + describe("when selectionMode is 'subset'", () => { + it("returns DeviceSelector with includeDevices containing device identifiers", () => { + const deviceIdentifiers = ["device-1", "device-2", "device-3"]; + const result = createDeviceSelector("subset", deviceIdentifiers); + + expect(result.selectionType.case).toBe("includeDevices"); + if (result.selectionType.case === "includeDevices") { + expect(result.selectionType.value?.deviceIdentifiers).toEqual(deviceIdentifiers); + } + }); + + it("returns empty includeDevices when no devices provided", () => { + const result = createDeviceSelector("subset", []); + + expect(result.selectionType.case).toBe("includeDevices"); + if (result.selectionType.case === "includeDevices") { + expect(result.selectionType.value?.deviceIdentifiers).toEqual([]); + } + }); + }); + + describe("when selectionMode is 'none'", () => { + it("throws an error", () => { + expect(() => createDeviceSelector("none", [])).toThrow("Cannot create DeviceSelector with no selection"); + }); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/utils/deviceSelector.ts b/client/src/protoFleet/features/fleetManagement/utils/deviceSelector.ts new file mode 100644 index 000000000..8eff67e29 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/utils/deviceSelector.ts @@ -0,0 +1,49 @@ +import { create } from "@bufbuild/protobuf"; +import { DeviceIdentifierListSchema } from "@/protoFleet/api/generated/common/v1/device_selector_pb"; +import { DeviceStatus, PairingStatus } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { + DeviceFilterSchema, + DeviceSelector, + DeviceSelectorSchema, +} from "@/protoFleet/api/generated/minercommand/v1/command_pb"; +import { type SelectionMode } from "@/shared/components/List"; + +export interface DeviceFilterCriteria { + deviceStatus?: DeviceStatus; + pairingStatus?: PairingStatus; +} + +/** + * Creates a DeviceSelector based on the selection mode. + * - "all": uses allDevices with optional filter criteria to target filtered miners + * - "subset": uses includeDevices with specific device identifiers + * - "none": throws an error (callers should disable actions when nothing is selected) + */ +export const createDeviceSelector = ( + selectionMode: SelectionMode, + deviceIdentifiers: string[], + filterCriteria?: DeviceFilterCriteria, +): DeviceSelector => { + if (selectionMode === "none") { + throw new Error("Cannot create DeviceSelector with no selection"); + } + if (selectionMode === "all") { + return create(DeviceSelectorSchema, { + selectionType: { + case: "allDevices", + value: create(DeviceFilterSchema, { + deviceStatus: filterCriteria?.deviceStatus ? [filterCriteria.deviceStatus] : [], + pairingStatus: filterCriteria?.pairingStatus ? [filterCriteria.pairingStatus] : [], + }), + }, + }); + } + return create(DeviceSelectorSchema, { + selectionType: { + case: "includeDevices", + value: create(DeviceIdentifierListSchema, { + deviceIdentifiers, + }), + }, + }); +}; diff --git a/client/src/protoFleet/features/fleetManagement/utils/filterUrlParams.test.ts b/client/src/protoFleet/features/fleetManagement/utils/filterUrlParams.test.ts new file mode 100644 index 000000000..5ca7e3872 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/utils/filterUrlParams.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import { encodeFilterToURL, parseFilterFromURL, parseUrlToActiveFilters } from "./filterUrlParams"; +import { MinerListFilterSchema } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { DeviceStatus } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; + +describe("filterUrlParams", () => { + describe("encodeFilterToURL", () => { + it("should not create duplicate status values when encoding needs-attention filter", () => { + const filter = create(MinerListFilterSchema, { + deviceStatus: [ + DeviceStatus.ERROR, + DeviceStatus.NEEDS_MINING_POOL, + DeviceStatus.UPDATING, + DeviceStatus.REBOOT_REQUIRED, + ], + }); + + const params = encodeFilterToURL(filter); + const statusParam = params.get("status"); + + expect(statusParam).toBe("needs-attention"); + expect(statusParam?.split(",").length).toBe(1); + }); + + it("should handle multiple different status values correctly", () => { + const filter = create(MinerListFilterSchema, { + deviceStatus: [DeviceStatus.ONLINE, DeviceStatus.ERROR, DeviceStatus.OFFLINE], + }); + + const params = encodeFilterToURL(filter); + const statusParam = params.get("status"); + + const statusValues = statusParam?.split(",").sort(); + expect(statusValues).toEqual(["hashing", "needs-attention", "offline"]); + }); + }); + + describe("parseUrlToActiveFilters", () => { + it("should deduplicate status values from URL", () => { + const params = new URLSearchParams("status=needs-attention,needs-attention"); + const activeFilters = parseUrlToActiveFilters(params); + + expect(activeFilters.dropdownFilters.status?.length).toBe(1); + }); + + it("should deduplicate issue values from URL", () => { + const params = new URLSearchParams("issues=control-board,control-board,fan,fan"); + const activeFilters = parseUrlToActiveFilters(params); + + expect(activeFilters.dropdownFilters.issues?.length).toBe(2); + expect(activeFilters.dropdownFilters.issues).toContain("control-board"); + expect(activeFilters.dropdownFilters.issues).toContain("fan"); + }); + + it("should deduplicate model values from URL", () => { + const params = new URLSearchParams("model=Proto Rig,Proto Rig"); + const activeFilters = parseUrlToActiveFilters(params); + + expect(activeFilters.dropdownFilters.model?.length).toBe(1); + }); + + it("should parse valid group IDs from URL", () => { + const params = new URLSearchParams("group=1,2,3"); + const activeFilters = parseUrlToActiveFilters(params); + + expect(activeFilters.dropdownFilters.group).toEqual(["1", "2", "3"]); + }); + + it("should deduplicate group values from URL", () => { + const params = new URLSearchParams("group=1,1,2,2"); + const activeFilters = parseUrlToActiveFilters(params); + + expect(activeFilters.dropdownFilters.group).toEqual(["1", "2"]); + }); + + it("should filter out empty group values from URL", () => { + const params = new URLSearchParams("group=1,,2,"); + const activeFilters = parseUrlToActiveFilters(params); + + expect(activeFilters.dropdownFilters.group).toEqual(["1", "2"]); + }); + + it("should filter out non-numeric group values from URL", () => { + const params = new URLSearchParams("group=1,abc,2,xyz"); + const activeFilters = parseUrlToActiveFilters(params); + + expect(activeFilters.dropdownFilters.group).toEqual(["1", "2"]); + }); + + it("should not set group filter when all values are invalid", () => { + const params = new URLSearchParams("group=abc,,xyz"); + const activeFilters = parseUrlToActiveFilters(params); + + expect(activeFilters.dropdownFilters.group).toBeUndefined(); + }); + }); + + describe("encodeFilterToURL - group IDs", () => { + it("should encode group IDs to URL params", () => { + const filter = create(MinerListFilterSchema, { + groupIds: [1n, 2n, 3n], + }); + + const params = encodeFilterToURL(filter); + + expect(params.get("group")).toBe("1,2,3"); + }); + + it("should not set group param when no group IDs", () => { + const filter = create(MinerListFilterSchema, {}); + + const params = encodeFilterToURL(filter); + + expect(params.has("group")).toBe(false); + }); + }); + + describe("parseFilterFromURL - group IDs", () => { + it("should parse valid group IDs into BigInt values", () => { + const params = new URLSearchParams("group=1,2,3"); + const filter = parseFilterFromURL(params); + + expect(filter?.groupIds).toEqual([1n, 2n, 3n]); + }); + + it("should skip empty group ID values", () => { + const params = new URLSearchParams("group=1,,3"); + const filter = parseFilterFromURL(params); + + expect(filter?.groupIds).toEqual([1n, 3n]); + }); + + it("should skip non-numeric group ID values without throwing", () => { + const params = new URLSearchParams("group=abc,1,xyz,2"); + const filter = parseFilterFromURL(params); + + expect(filter?.groupIds).toEqual([1n, 2n]); + }); + + it("should handle group param with only invalid values", () => { + const params = new URLSearchParams("group=abc"); + const filter = parseFilterFromURL(params); + + expect(filter?.groupIds).toEqual([]); + }); + + it("should return undefined when no filter params present", () => { + const params = new URLSearchParams(); + const filter = parseFilterFromURL(params); + + expect(filter).toBeUndefined(); + }); + }); + + describe("parseFilterFromURL - needs attention", () => { + it("should expand needs-attention URL state to all attention statuses", () => { + const params = new URLSearchParams("status=needs-attention"); + const filter = parseFilterFromURL(params); + + expect(filter?.deviceStatus).toEqual([ + DeviceStatus.ERROR, + DeviceStatus.NEEDS_MINING_POOL, + DeviceStatus.UPDATING, + DeviceStatus.REBOOT_REQUIRED, + ]); + }); + }); + + describe("parseUrlToActiveFilters - rack IDs", () => { + it("should parse valid rack IDs from URL", () => { + const params = new URLSearchParams("rack=10,20,30"); + const activeFilters = parseUrlToActiveFilters(params); + + expect(activeFilters.dropdownFilters.rack).toEqual(["10", "20", "30"]); + }); + + it("should deduplicate rack values from URL", () => { + const params = new URLSearchParams("rack=5,5,6,6"); + const activeFilters = parseUrlToActiveFilters(params); + + expect(activeFilters.dropdownFilters.rack).toEqual(["5", "6"]); + }); + + it("should filter out empty rack values from URL", () => { + const params = new URLSearchParams("rack=1,,2,"); + const activeFilters = parseUrlToActiveFilters(params); + + expect(activeFilters.dropdownFilters.rack).toEqual(["1", "2"]); + }); + + it("should filter out non-numeric rack values from URL", () => { + const params = new URLSearchParams("rack=1,abc,2,xyz"); + const activeFilters = parseUrlToActiveFilters(params); + + expect(activeFilters.dropdownFilters.rack).toEqual(["1", "2"]); + }); + + it("should not set rack filter when all values are invalid", () => { + const params = new URLSearchParams("rack=abc,,xyz"); + const activeFilters = parseUrlToActiveFilters(params); + + expect(activeFilters.dropdownFilters.rack).toBeUndefined(); + }); + }); + + describe("encodeFilterToURL - rack IDs", () => { + it("should encode rack IDs to URL params", () => { + const filter = create(MinerListFilterSchema, { + rackIds: [10n, 20n, 30n], + }); + + const params = encodeFilterToURL(filter); + + expect(params.get("rack")).toBe("10,20,30"); + }); + + it("should not set rack param when no rack IDs", () => { + const filter = create(MinerListFilterSchema, {}); + + const params = encodeFilterToURL(filter); + + expect(params.has("rack")).toBe(false); + }); + }); + + describe("parseFilterFromURL - rack IDs", () => { + it("should parse valid rack IDs into BigInt values", () => { + const params = new URLSearchParams("rack=10,20,30"); + const filter = parseFilterFromURL(params); + + expect(filter?.rackIds).toEqual([10n, 20n, 30n]); + }); + + it("should skip empty rack ID values", () => { + const params = new URLSearchParams("rack=1,,3"); + const filter = parseFilterFromURL(params); + + expect(filter?.rackIds).toEqual([1n, 3n]); + }); + + it("should skip non-numeric rack ID values without throwing", () => { + const params = new URLSearchParams("rack=abc,1,xyz,2"); + const filter = parseFilterFromURL(params); + + expect(filter?.rackIds).toEqual([1n, 2n]); + }); + + it("should handle rack param with only invalid values", () => { + const params = new URLSearchParams("rack=abc"); + const filter = parseFilterFromURL(params); + + expect(filter?.rackIds).toEqual([]); + }); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/utils/filterUrlParams.ts b/client/src/protoFleet/features/fleetManagement/utils/filterUrlParams.ts new file mode 100644 index 000000000..053c0e791 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/utils/filterUrlParams.ts @@ -0,0 +1,322 @@ +import { create } from "@bufbuild/protobuf"; +import { componentIssues, deviceStatusFilterStates } from "../components/MinerList/constants"; +import { ComponentType } from "@/protoFleet/api/generated/errors/v1/errors_pb"; +import { + DeviceStatus, + type MinerListFilter, + MinerListFilterSchema, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import type { ActiveFilters } from "@/shared/components/List/Filters/types"; + +/** + * URL parameter keys for filter state + */ +const URL_PARAMS = { + STATUS: "status", + ISSUES: "issues", + MODEL: "model", + GROUP: "group", + RACK: "rack", +} as const; + +/** + * Maps device status filter states to URL values + */ +const STATUS_TO_URL: Record = { + [deviceStatusFilterStates.hashing]: "hashing", + [deviceStatusFilterStates.offline]: "offline", + [deviceStatusFilterStates.sleeping]: "sleeping", + [deviceStatusFilterStates.needsAttention]: "needs-attention", +}; + +/** + * Maps URL values to device status filter states + */ +const URL_TO_STATUS: Record = { + hashing: deviceStatusFilterStates.hashing, + offline: deviceStatusFilterStates.offline, + sleeping: deviceStatusFilterStates.sleeping, + "needs-attention": deviceStatusFilterStates.needsAttention, +}; + +/** + * Encodes a MinerListFilter to URL search parameters + */ +export function encodeFilterToURL(filter: MinerListFilter): URLSearchParams { + const params = new URLSearchParams(); + + // Encode device statuses + if (filter.deviceStatus.length > 0) { + const statusValues = new Set(); + filter.deviceStatus.forEach((status) => { + switch (status) { + case DeviceStatus.ONLINE: + statusValues.add("hashing"); + break; + case DeviceStatus.ERROR: + case DeviceStatus.NEEDS_MINING_POOL: + case DeviceStatus.UPDATING: + case DeviceStatus.REBOOT_REQUIRED: + statusValues.add("needs-attention"); + break; + case DeviceStatus.OFFLINE: + statusValues.add("offline"); + break; + case DeviceStatus.INACTIVE: + statusValues.add("sleeping"); + break; + } + }); + if (statusValues.size > 0) { + params.set(URL_PARAMS.STATUS, Array.from(statusValues).sort().join(",")); + } + } + + // Encode error component types (issues) + if (filter.errorComponentTypes.length > 0) { + const issueValues = new Set(); + filter.errorComponentTypes.forEach((componentType) => { + switch (componentType) { + case ComponentType.CONTROL_BOARD: + issueValues.add(componentIssues.controlBoard); + break; + case ComponentType.FAN: + issueValues.add(componentIssues.fans); + break; + case ComponentType.HASH_BOARD: + issueValues.add(componentIssues.hashBoards); + break; + case ComponentType.PSU: + issueValues.add(componentIssues.psu); + break; + } + }); + if (issueValues.size > 0) { + params.set(URL_PARAMS.ISSUES, Array.from(issueValues).sort().join(",")); + } + } + + // Encode models + if (filter.models.length > 0) { + params.set(URL_PARAMS.MODEL, filter.models.sort().join(",")); + } + + // Encode group IDs + if (filter.groupIds.length > 0) { + params.set(URL_PARAMS.GROUP, filter.groupIds.map(String).sort().join(",")); + } + + // Encode rack IDs + if (filter.rackIds.length > 0) { + params.set(URL_PARAMS.RACK, filter.rackIds.map(String).sort().join(",")); + } + + return params; +} + +/** + * Parses URL search parameters into a MinerListFilter + */ +export function parseFilterFromURL(params: URLSearchParams): MinerListFilter | undefined { + const statusParam = params.get(URL_PARAMS.STATUS); + const issuesParam = params.get(URL_PARAMS.ISSUES); + const modelParam = params.get(URL_PARAMS.MODEL); + const groupParam = params.get(URL_PARAMS.GROUP); + const rackParam = params.get(URL_PARAMS.RACK); + + // If no filter params, return undefined + if (!statusParam && !issuesParam && !modelParam && !groupParam && !rackParam) { + return undefined; + } + + const filter = create(MinerListFilterSchema, { + errorComponentTypes: [], + }); + + // Parse device statuses + if (statusParam) { + const statusValues = statusParam.split(","); + statusValues.forEach((value) => { + switch (value) { + case "hashing": + filter.deviceStatus.push(DeviceStatus.ONLINE); + break; + case "needs-attention": + filter.deviceStatus.push(DeviceStatus.ERROR); + filter.deviceStatus.push(DeviceStatus.NEEDS_MINING_POOL); + filter.deviceStatus.push(DeviceStatus.UPDATING); + filter.deviceStatus.push(DeviceStatus.REBOOT_REQUIRED); + break; + case "offline": + filter.deviceStatus.push(DeviceStatus.OFFLINE); + break; + case "sleeping": + filter.deviceStatus.push(DeviceStatus.INACTIVE); + break; + } + }); + } + + // Parse component issues + if (issuesParam) { + const issueValues = issuesParam.split(","); + issueValues.forEach((issue) => { + switch (issue) { + case componentIssues.controlBoard: + filter.errorComponentTypes.push(ComponentType.CONTROL_BOARD); + break; + case componentIssues.fans: + filter.errorComponentTypes.push(ComponentType.FAN); + break; + case componentIssues.hashBoards: + filter.errorComponentTypes.push(ComponentType.HASH_BOARD); + break; + case componentIssues.psu: + filter.errorComponentTypes.push(ComponentType.PSU); + break; + default: + return; // Skip unknown issues + } + }); + } + + // Parse models + if (modelParam) { + const modelValues = modelParam.split(","); + modelValues.forEach((model) => { + if (model) { + filter.models.push(model); + } + }); + } + + // Parse group IDs + if (groupParam) { + const groupValues = groupParam.split(","); + groupValues.forEach((id) => { + const trimmed = id.trim(); + if (trimmed && /^\d+$/.test(trimmed)) { + filter.groupIds.push(BigInt(trimmed)); + } + }); + } + + // Parse rack IDs + if (rackParam) { + const rackValues = rackParam.split(","); + rackValues.forEach((id) => { + const trimmed = id.trim(); + if (trimmed && /^\d+$/.test(trimmed)) { + filter.rackIds.push(BigInt(trimmed)); + } + }); + } + + return filter; +} + +/** + * Converts URL search parameters to ActiveFilters format used by the UI + */ +export function parseUrlToActiveFilters(params: URLSearchParams): ActiveFilters { + const activeFilters: ActiveFilters = { + buttonFilters: [], + dropdownFilters: {}, + }; + + // Parse status dropdown + const statusParam = params.get(URL_PARAMS.STATUS); + if (statusParam) { + const statusValues = statusParam.split(","); + const mappedStatuses = statusValues.map((v) => URL_TO_STATUS[v]).filter(Boolean); + // Deduplicate to prevent infinite loops from duplicate URL params + const uniqueStatuses = Array.from(new Set(mappedStatuses)); + if (uniqueStatuses.length > 0) { + activeFilters.dropdownFilters.status = uniqueStatuses; + } + } + + // Parse issues dropdown + const issuesParam = params.get(URL_PARAMS.ISSUES); + if (issuesParam) { + const issueValues = issuesParam.split(","); + // Deduplicate to prevent infinite loops from duplicate URL params + activeFilters.dropdownFilters.issues = Array.from(new Set(issueValues)); + } + + // Parse model dropdown + const modelParam = params.get(URL_PARAMS.MODEL); + if (modelParam) { + const modelValues = modelParam.split(","); + // Deduplicate to prevent infinite loops from duplicate URL params + activeFilters.dropdownFilters.model = Array.from(new Set(modelValues)); + } + + // Parse group dropdown + const groupParam = params.get(URL_PARAMS.GROUP); + if (groupParam) { + const groupValues = groupParam + .split(",") + .map((value) => value.trim()) + .filter((value) => value !== "" && /^\d+$/.test(value)); + if (groupValues.length > 0) { + activeFilters.dropdownFilters.group = Array.from(new Set(groupValues)); + } + } + + // Parse rack dropdown + const rackParam = params.get(URL_PARAMS.RACK); + if (rackParam) { + const rackValues = rackParam + .split(",") + .map((value) => value.trim()) + .filter((value) => value !== "" && /^\d+$/.test(value)); + if (rackValues.length > 0) { + activeFilters.dropdownFilters.rack = Array.from(new Set(rackValues)); + } + } + + return activeFilters; +} + +/** + * Converts ActiveFilters to URL search parameters + */ +export function encodeActiveFiltersToURL(filters: ActiveFilters): URLSearchParams { + const params = new URLSearchParams(); + + // Encode status dropdown + const statusFilters = filters.dropdownFilters.status; + if (statusFilters && statusFilters.length > 0) { + const urlValues = statusFilters.map((s) => STATUS_TO_URL[s]).filter(Boolean); + if (urlValues.length > 0) { + params.set(URL_PARAMS.STATUS, urlValues.sort().join(",")); + } + } + + // Encode issues dropdown + const issueFilters = filters.dropdownFilters.issues; + if (issueFilters && issueFilters.length > 0) { + params.set(URL_PARAMS.ISSUES, issueFilters.sort().join(",")); + } + + // Encode model dropdown + const modelFilters = filters.dropdownFilters.model; + if (modelFilters && modelFilters.length > 0) { + params.set(URL_PARAMS.MODEL, modelFilters.sort().join(",")); + } + + // Encode group dropdown + const groupFilters = filters.dropdownFilters.group; + if (groupFilters && groupFilters.length > 0) { + params.set(URL_PARAMS.GROUP, groupFilters.sort().join(",")); + } + + // Encode rack dropdown + const rackFilters = filters.dropdownFilters.rack; + if (rackFilters && rackFilters.length > 0) { + params.set(URL_PARAMS.RACK, rackFilters.sort().join(",")); + } + + return params; +} diff --git a/client/src/protoFleet/features/fleetManagement/utils/fleetVisiblePairingFilter.test.ts b/client/src/protoFleet/features/fleetManagement/utils/fleetVisiblePairingFilter.test.ts new file mode 100644 index 000000000..df9b42c4b --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/utils/fleetVisiblePairingFilter.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import { + applyFleetSelectablePairingStatuses, + applyFleetVisiblePairingStatuses, + FLEET_SELECTABLE_PAIRING_STATUSES, + FLEET_VISIBLE_PAIRING_STATUSES, + isFleetSelectablePairingStatus, +} from "./fleetVisiblePairingFilter"; +import { + type MinerListFilter, + MinerListFilterSchema, + PairingStatus, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +describe("applyFleetVisiblePairingStatuses", () => { + it("defaults to the fleet-visible pairing statuses when the filter is undefined", () => { + expect(applyFleetVisiblePairingStatuses().pairingStatuses).toEqual([...FLEET_VISIBLE_PAIRING_STATUSES]); + }); + + it("preserves existing visible pairing statuses", () => { + const filter: MinerListFilter = create(MinerListFilterSchema, { + pairingStatuses: [PairingStatus.AUTHENTICATION_NEEDED], + }); + + expect(applyFleetVisiblePairingStatuses(filter).pairingStatuses).toEqual([PairingStatus.AUTHENTICATION_NEEDED]); + }); + + it("filters out non-visible pairing statuses", () => { + const filter: MinerListFilter = create(MinerListFilterSchema, { + pairingStatuses: [PairingStatus.PAIRED, PairingStatus.PENDING], + }); + + expect(applyFleetVisiblePairingStatuses(filter).pairingStatuses).toEqual([PairingStatus.PAIRED]); + }); + + it("preserves an empty intersection when an explicit filter contains no visible statuses", () => { + const filter: MinerListFilter = create(MinerListFilterSchema, { + pairingStatuses: [PairingStatus.PENDING], + }); + + expect(applyFleetVisiblePairingStatuses(filter).pairingStatuses).toEqual([]); + }); +}); + +describe("applyFleetSelectablePairingStatuses", () => { + it("defaults to the fleet-selectable pairing statuses when the filter is undefined", () => { + expect(applyFleetSelectablePairingStatuses().pairingStatuses).toEqual([...FLEET_SELECTABLE_PAIRING_STATUSES]); + }); + + it("filters out non-selectable pairing statuses", () => { + const filter: MinerListFilter = create(MinerListFilterSchema, { + pairingStatuses: [PairingStatus.PAIRED, PairingStatus.AUTHENTICATION_NEEDED], + }); + + expect(applyFleetSelectablePairingStatuses(filter).pairingStatuses).toEqual([PairingStatus.PAIRED]); + }); + + it("preserves an empty selectable intersection for explicit non-selectable filters", () => { + const filter: MinerListFilter = create(MinerListFilterSchema, { + pairingStatuses: [PairingStatus.AUTHENTICATION_NEEDED], + }); + + expect(applyFleetSelectablePairingStatuses(filter).pairingStatuses).toEqual([]); + }); +}); + +describe("isFleetSelectablePairingStatus", () => { + it("returns true only for pairing statuses that can be selected in the miner list", () => { + expect(isFleetSelectablePairingStatus(PairingStatus.PAIRED)).toBe(true); + expect(isFleetSelectablePairingStatus(PairingStatus.AUTHENTICATION_NEEDED)).toBe(false); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/utils/fleetVisiblePairingFilter.ts b/client/src/protoFleet/features/fleetManagement/utils/fleetVisiblePairingFilter.ts new file mode 100644 index 000000000..b5f1f872d --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/utils/fleetVisiblePairingFilter.ts @@ -0,0 +1,45 @@ +import { create } from "@bufbuild/protobuf"; +import { + type MinerListFilter, + MinerListFilterSchema, + PairingStatus, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +export const FLEET_VISIBLE_PAIRING_STATUSES: PairingStatus[] = [ + PairingStatus.PAIRED, + PairingStatus.AUTHENTICATION_NEEDED, +]; + +export const FLEET_SELECTABLE_PAIRING_STATUSES: PairingStatus[] = [PairingStatus.PAIRED]; + +const fleetVisiblePairingStatusSet = new Set(FLEET_VISIBLE_PAIRING_STATUSES); +const fleetSelectablePairingStatusSet = new Set(FLEET_SELECTABLE_PAIRING_STATUSES); + +const applyAllowedPairingStatuses = ( + filter: MinerListFilter | undefined, + allowedPairingStatuses: PairingStatus[], + allowedPairingStatusSet: Set, +): MinerListFilter => { + const requestedPairingStatuses = filter?.pairingStatuses ?? []; + const pairingStatuses = requestedPairingStatuses.filter((status) => allowedPairingStatusSet.has(status)); + const hasExplicitPairingStatuses = requestedPairingStatuses.length > 0; + + return create(MinerListFilterSchema, { + deviceStatus: filter?.deviceStatus ?? [], + errorComponentTypes: filter?.errorComponentTypes ?? [], + models: filter?.models ?? [], + pairingStatuses: + pairingStatuses.length > 0 || hasExplicitPairingStatuses ? pairingStatuses : [...allowedPairingStatuses], + groupIds: filter?.groupIds ?? [], + rackIds: filter?.rackIds ?? [], + }); +}; + +export const isFleetSelectablePairingStatus = (pairingStatus: PairingStatus): boolean => + fleetSelectablePairingStatusSet.has(pairingStatus); + +export const applyFleetVisiblePairingStatuses = (filter?: MinerListFilter): MinerListFilter => + applyAllowedPairingStatuses(filter, FLEET_VISIBLE_PAIRING_STATUSES, fleetVisiblePairingStatusSet); + +export const applyFleetSelectablePairingStatuses = (filter?: MinerListFilter): MinerListFilter => + applyAllowedPairingStatuses(filter, FLEET_SELECTABLE_PAIRING_STATUSES, fleetSelectablePairingStatusSet); diff --git a/client/src/protoFleet/features/fleetManagement/utils/getMinerMeasurement.test.ts b/client/src/protoFleet/features/fleetManagement/utils/getMinerMeasurement.test.ts new file mode 100644 index 000000000..1ba22ffa4 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/utils/getMinerMeasurement.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import { TimestampSchema } from "@bufbuild/protobuf/wkt"; +import { getMinerMeasurement } from "./getMinerMeasurement"; +import type { Measurement } from "@/protoFleet/api/generated/common/v1/measurement_pb"; +import { MeasurementSchema } from "@/protoFleet/api/generated/common/v1/measurement_pb"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { PairingStatus } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { DeviceStatus } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; + +const createMeasurement = (value: number, timestamp = new Date()): Measurement => { + return create(MeasurementSchema, { + value: value, + timestamp: create(TimestampSchema, { seconds: BigInt(Math.floor(timestamp.getTime() / 1000)) }), + }); +}; + +const createMinerSnapshot = (overrides: Partial = {}): MinerStateSnapshot => { + return { + deviceIdentifier: "test-device-id", + name: "Test Miner", + macAddress: "00:00:00:00:00:00", + ipAddress: "192.168.1.1", + deviceStatus: DeviceStatus.ONLINE, + pairingStatus: 1, + hashrate: [], + efficiency: [], + powerUsage: [], + temperature: [], + errors: [], + url: "", + model: "", + firmwareVersion: "", + ...overrides, + } as MinerStateSnapshot; +}; + +const hashrateGetter = (miner: MinerStateSnapshot) => miner.hashrate; + +describe("getMinerMeasurement", () => { + it("returns undefined when miner is undefined", () => { + expect(getMinerMeasurement(undefined, hashrateGetter)).toBeUndefined(); + }); + + it("returns undefined when miner is online but has no telemetry data", () => { + const miner = createMinerSnapshot({ + deviceStatus: DeviceStatus.ONLINE, + hashrate: [], + }); + expect(getMinerMeasurement(miner, hashrateGetter)).toBeUndefined(); + }); + + it("returns null when miner is offline", () => { + const miner = createMinerSnapshot({ + deviceStatus: DeviceStatus.OFFLINE, + hashrate: [createMeasurement(100)], + }); + expect(getMinerMeasurement(miner, hashrateGetter)).toBeNull(); + }); + + it("returns null when miner is offline and has no telemetry data", () => { + const miner = createMinerSnapshot({ + deviceStatus: DeviceStatus.OFFLINE, + hashrate: [], + }); + expect(getMinerMeasurement(miner, hashrateGetter)).toBeNull(); + }); + + it("returns null when miner is inactive and has no telemetry data", () => { + const miner = createMinerSnapshot({ + deviceStatus: DeviceStatus.INACTIVE, + hashrate: [], + }); + expect(getMinerMeasurement(miner, hashrateGetter)).toBeNull(); + }); + + it("returns measurement data when miner is online with valid data", () => { + const hashrateData = [createMeasurement(100), createMeasurement(110)]; + const miner = createMinerSnapshot({ + deviceStatus: DeviceStatus.ONLINE, + hashrate: hashrateData, + }); + expect(getMinerMeasurement(miner, hashrateGetter)).toEqual(hashrateData); + }); + + it("returns measurement data when value is 0 (valid data)", () => { + const hashrateData = [createMeasurement(0)]; + const miner = createMinerSnapshot({ + deviceStatus: DeviceStatus.ONLINE, + hashrate: hashrateData, + }); + expect(getMinerMeasurement(miner, hashrateGetter)).toEqual(hashrateData); + }); + + it("returns undefined when miner is online but measurements have no valid data", () => { + const hashrateData = [create(MeasurementSchema, {}), create(MeasurementSchema, {})]; + const miner = createMinerSnapshot({ + deviceStatus: DeviceStatus.ONLINE, + hashrate: hashrateData, + }); + expect(getMinerMeasurement(miner, hashrateGetter)).toBeUndefined(); + }); + + it("returns empty array when miner needs pool", () => { + const miner = createMinerSnapshot({ + deviceStatus: DeviceStatus.NEEDS_MINING_POOL, + hashrate: [createMeasurement(100)], + }); + const result = getMinerMeasurement(miner, hashrateGetter); + expect(result).toEqual([]); + expect(result).toHaveLength(0); + }); + + it("returns empty array when miner needs authentication", () => { + const miner = createMinerSnapshot({ + deviceStatus: DeviceStatus.ONLINE, + pairingStatus: PairingStatus.AUTHENTICATION_NEEDED, + hashrate: [createMeasurement(100)], + }); + const result = getMinerMeasurement(miner, hashrateGetter); + expect(result).toEqual([]); + expect(result).toHaveLength(0); + }); + + it("returns stable empty array reference for needs-pool state", () => { + const miner = createMinerSnapshot({ + deviceStatus: DeviceStatus.NEEDS_MINING_POOL, + }); + const result1 = getMinerMeasurement(miner, hashrateGetter); + const result2 = getMinerMeasurement(miner, hashrateGetter); + expect(result1).toBe(result2); // Same reference + }); + + it("works with different measurement getters", () => { + const efficiencyData = [createMeasurement(25.5)]; + const miner = createMinerSnapshot({ + deviceStatus: DeviceStatus.ONLINE, + efficiency: efficiencyData, + }); + expect(getMinerMeasurement(miner, (m) => m.efficiency)).toEqual(efficiencyData); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/utils/getMinerMeasurement.ts b/client/src/protoFleet/features/fleetManagement/utils/getMinerMeasurement.ts new file mode 100644 index 000000000..99ca569d9 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/utils/getMinerMeasurement.ts @@ -0,0 +1,50 @@ +import type { Measurement } from "@/protoFleet/api/generated/common/v1/measurement_pb"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { PairingStatus } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { DeviceStatus } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { getLatestMeasurementWithData } from "@/shared/utils/measurementUtils"; + +// Stable reference for empty measurement array (prevents infinite re-renders when used in components) +const EMPTY_MEASUREMENT: Measurement[] = []; + +/** + * Pure function for resolving miner measurement display state. + * + * @param miner - The miner state snapshot (or undefined if not loaded) + * @param measurementGetter - Function to extract the specific measurement from a miner + * @returns Display state: + * - `undefined` — miner not loaded OR online with no data yet (show skeleton) + * - `null` — offline or inactive with no data (show dash placeholder) + * - `[]` — needs pool or auth (show empty cell) + * - `Measurement[]` — has valid data (show value) + */ +export function getMinerMeasurement( + miner: MinerStateSnapshot | undefined, + measurementGetter: (miner: MinerStateSnapshot) => Measurement[] | undefined, +): Measurement[] | null | undefined { + if (!miner) return undefined; + + // Offline miners should always show placeholder, not stale cached values + if (miner.deviceStatus === DeviceStatus.OFFLINE) { + return null; + } + + // Show empty cell for devices with pool required or auth required status + const needsPool = miner.deviceStatus === DeviceStatus.NEEDS_MINING_POOL; + const needsAuth = miner.pairingStatus === PairingStatus.AUTHENTICATION_NEEDED; + if (needsPool || needsAuth) { + return EMPTY_MEASUREMENT; + } + + const measurementData = measurementGetter(miner); + const hasValidData = measurementData && getLatestMeasurementWithData(measurementData); + + if (!hasValidData) { + if (miner.deviceStatus === DeviceStatus.INACTIVE) { + return null; + } + return undefined; + } + + return measurementData; +} diff --git a/client/src/protoFleet/features/fleetManagement/utils/sortUrlParams.test.ts b/client/src/protoFleet/features/fleetManagement/utils/sortUrlParams.test.ts new file mode 100644 index 000000000..7556c6320 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/utils/sortUrlParams.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it, vi } from "vitest"; + +import { encodeSortToURL, parseSortFromURL } from "./sortUrlParams"; +import { SortDirection, SortField } from "@/protoFleet/api/generated/common/v1/sort_pb"; + +describe("sortUrlParams", () => { + describe("parseSortFromURL", () => { + it("returns undefined when no sort param is present", () => { + // Act + const result = parseSortFromURL(new URLSearchParams()); + + // Assert + expect(result).toBeUndefined(); + }); + + it("parses hashrate with desc direction", () => { + // Act + const result = parseSortFromURL(new URLSearchParams("sort=hashrate&dir=desc")); + + // Assert + expect(result).toEqual( + expect.objectContaining({ + field: SortField.HASHRATE, + direction: SortDirection.DESC, + }), + ); + }); + + it("parses name with asc direction", () => { + // Act + const result = parseSortFromURL(new URLSearchParams("sort=name&dir=asc")); + + // Assert + expect(result).toEqual( + expect.objectContaining({ + field: SortField.NAME, + direction: SortDirection.ASC, + }), + ); + }); + + it("defaults to DESC when dir param is missing", () => { + // Act + const result = parseSortFromURL(new URLSearchParams("sort=hashrate")); + + // Assert + expect(result).toEqual( + expect.objectContaining({ + field: SortField.HASHRATE, + direction: SortDirection.DESC, + }), + ); + }); + + it("handles case-insensitive field names", () => { + // Act + const result = parseSortFromURL(new URLSearchParams("sort=HASHRATE&dir=desc")); + + // Assert + expect(result).toEqual( + expect.objectContaining({ + field: SortField.HASHRATE, + direction: SortDirection.DESC, + }), + ); + }); + + it("returns undefined and logs warning for unknown sort field", () => { + // Arrange + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + // Act + const result = parseSortFromURL(new URLSearchParams("sort=unknown&dir=asc")); + + // Assert + expect(result).toBeUndefined(); + expect(consoleSpy).toHaveBeenCalledWith("Unknown sort field in URL: unknown"); + consoleSpy.mockRestore(); + }); + + it("parses all supported sort fields", () => { + const fieldMappings: Array<{ url: string; expected: SortField }> = [ + { url: "name", expected: SortField.NAME }, + { url: "worker-name", expected: SortField.WORKER_NAME }, + { url: "ip", expected: SortField.IP_ADDRESS }, + { url: "mac", expected: SortField.MAC_ADDRESS }, + { url: "model", expected: SortField.MODEL }, + { url: "hashrate", expected: SortField.HASHRATE }, + { url: "temp", expected: SortField.TEMPERATURE }, + { url: "power", expected: SortField.POWER }, + { url: "efficiency", expected: SortField.EFFICIENCY }, + { url: "firmware", expected: SortField.FIRMWARE }, + ]; + + for (const { url, expected } of fieldMappings) { + // Act + const result = parseSortFromURL(new URLSearchParams(`sort=${url}&dir=asc`)); + + // Assert + expect(result?.field, `Failed for field: ${url}`).toBe(expected); + } + }); + }); + + describe("encodeSortToURL", () => { + it("removes sort params when sort is undefined", () => { + // Arrange + const params = new URLSearchParams("sort=hashrate&dir=desc"); + + // Act + encodeSortToURL(params, undefined); + + // Assert + expect(params.has("sort")).toBe(false); + expect(params.has("dir")).toBe(false); + }); + + it("encodes hashrate with desc direction", () => { + // Arrange + const params = new URLSearchParams(); + + // Act + encodeSortToURL(params, { + field: SortField.HASHRATE, + direction: SortDirection.DESC, + $typeName: "common.v1.SortConfig", + } as any); + + // Assert + expect(params.get("sort")).toBe("hashrate"); + expect(params.get("dir")).toBe("desc"); + }); + + it("encodes name with asc direction", () => { + // Arrange + const params = new URLSearchParams(); + + // Act + encodeSortToURL(params, { + field: SortField.NAME, + direction: SortDirection.ASC, + $typeName: "common.v1.SortConfig", + } as any); + + // Assert + expect(params.get("sort")).toBe("name"); + expect(params.get("dir")).toBe("asc"); + }); + + it("preserves existing filter params", () => { + // Arrange + const params = new URLSearchParams("status=hashing,offline"); + + // Act + encodeSortToURL(params, { + field: SortField.HASHRATE, + direction: SortDirection.DESC, + $typeName: "common.v1.SortConfig", + } as any); + + // Assert + expect(params.get("status")).toBe("hashing,offline"); + expect(params.get("sort")).toBe("hashrate"); + expect(params.get("dir")).toBe("desc"); + }); + + it("encodes all supported sort fields", () => { + const fieldMappings: Array<{ field: SortField; expected: string }> = [ + { field: SortField.NAME, expected: "name" }, + { field: SortField.WORKER_NAME, expected: "worker-name" }, + { field: SortField.IP_ADDRESS, expected: "ip" }, + { field: SortField.MAC_ADDRESS, expected: "mac" }, + { field: SortField.MODEL, expected: "model" }, + { field: SortField.HASHRATE, expected: "hashrate" }, + { field: SortField.TEMPERATURE, expected: "temp" }, + { field: SortField.POWER, expected: "power" }, + { field: SortField.EFFICIENCY, expected: "efficiency" }, + { field: SortField.FIRMWARE, expected: "firmware" }, + ]; + + for (const { field, expected } of fieldMappings) { + // Arrange + const params = new URLSearchParams(); + + // Act + encodeSortToURL(params, { + field, + direction: SortDirection.ASC, + $typeName: "common.v1.SortConfig", + } as any); + + // Assert + expect(params.get("sort"), `Failed for field: ${field}`).toBe(expected); + } + }); + }); + + describe("round-trip", () => { + it("maintains sort config through encode-decode cycle", () => { + // Arrange + const original = parseSortFromURL(new URLSearchParams("sort=efficiency&dir=desc")); + + // Act + const params = new URLSearchParams(); + encodeSortToURL(params, original); + const decoded = parseSortFromURL(params); + + // Assert + expect(decoded?.field).toBe(SortField.EFFICIENCY); + expect(decoded?.direction).toBe(SortDirection.DESC); + }); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/utils/sortUrlParams.ts b/client/src/protoFleet/features/fleetManagement/utils/sortUrlParams.ts new file mode 100644 index 000000000..3356aad6b --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/utils/sortUrlParams.ts @@ -0,0 +1,101 @@ +import { create } from "@bufbuild/protobuf"; +import { + type SortConfig, + SortConfigSchema, + SortDirection, + SortField, +} from "@/protoFleet/api/generated/common/v1/sort_pb"; +import { SORT_ASC, SORT_DESC } from "@/shared/components/List/types"; + +/** + * URL parameter keys for sort state + */ +const URL_PARAMS = { + SORT: "sort", + DIR: "dir", +} as const; + +/** + * Maps URL field values to SortField enum. + * Keys are lowercase for case-insensitive parsing. + */ +const URL_TO_SORT_FIELD: Record = { + name: SortField.NAME, + "worker-name": SortField.WORKER_NAME, + ip: SortField.IP_ADDRESS, + mac: SortField.MAC_ADDRESS, + model: SortField.MODEL, + hashrate: SortField.HASHRATE, + temp: SortField.TEMPERATURE, + power: SortField.POWER, + efficiency: SortField.EFFICIENCY, + firmware: SortField.FIRMWARE, +}; + +/** + * Maps SortField enum to URL field values. + * Excludes UNSPECIFIED since that means no sort. + */ +const SORT_FIELD_TO_URL: Partial> = { + [SortField.NAME]: "name", + [SortField.WORKER_NAME]: "worker-name", + [SortField.IP_ADDRESS]: "ip", + [SortField.MAC_ADDRESS]: "mac", + [SortField.MODEL]: "model", + [SortField.HASHRATE]: "hashrate", + [SortField.TEMPERATURE]: "temp", + [SortField.POWER]: "power", + [SortField.EFFICIENCY]: "efficiency", + [SortField.FIRMWARE]: "firmware", +}; + +/** + * Parses sort configuration from URL search parameters. + * Returns undefined if no valid sort params are present. + * + * @example + * // URL: ?sort=hashrate&dir=desc + * parseSortFromURL(params) // MinerSortConfig { field: HASHRATE, direction: DESC } + */ +export function parseSortFromURL(params: URLSearchParams): SortConfig | undefined { + const sortParam = params.get(URL_PARAMS.SORT); + if (!sortParam) { + return undefined; + } + + const field = URL_TO_SORT_FIELD[sortParam.toLowerCase()]; + if (field === undefined) { + console.warn(`Unknown sort field in URL: ${sortParam}`); + return undefined; + } + + const dirParam = params.get(URL_PARAMS.DIR); + const direction = dirParam === SORT_ASC ? SortDirection.ASC : SortDirection.DESC; + + return create(SortConfigSchema, { field, direction }); +} + +/** + * Encodes sort configuration to URL search parameters. + * If sort is undefined or UNSPECIFIED, removes sort params from URL. + * + * @example + * encodeSortToURL(params, { field: SortField.HASHRATE, direction: SortDirection.DESC }) + * // params now has: sort=hashrate&dir=desc + */ +export function encodeSortToURL(params: URLSearchParams, sort: SortConfig | undefined): void { + if (!sort || sort.field === SortField.UNSPECIFIED) { + params.delete(URL_PARAMS.SORT); + params.delete(URL_PARAMS.DIR); + return; + } + + const urlField = SORT_FIELD_TO_URL[sort.field]; + if (!urlField) { + console.warn(`No URL mapping for sort field: ${sort.field}`); + return; + } + + params.set(URL_PARAMS.SORT, urlField); + params.set(URL_PARAMS.DIR, sort.direction === SortDirection.ASC ? SORT_ASC : SORT_DESC); +} diff --git a/client/src/protoFleet/features/groupManagement/components/DeviceSetActionsMenu.test.tsx b/client/src/protoFleet/features/groupManagement/components/DeviceSetActionsMenu.test.tsx new file mode 100644 index 000000000..0d803412c --- /dev/null +++ b/client/src/protoFleet/features/groupManagement/components/DeviceSetActionsMenu.test.tsx @@ -0,0 +1,529 @@ +import { Fragment, type ReactNode } from "react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import DeviceSetActionsMenu from "./DeviceSetActionsMenu"; + +// Hoisted mocks +const { mockUseMinerActions, mockBulkActionsPopover, mockListGroupMembers, mockFetchAllMinerSnapshots } = vi.hoisted( + () => ({ + mockUseMinerActions: vi.fn(() => ({ + currentAction: null, + popoverActions: [], + handleConfirmation: vi.fn(), + handleCancel: vi.fn(), + handleMiningPoolSuccess: vi.fn(), + handleMiningPoolError: vi.fn(), + showPoolSelectionPage: false, + poolFilteredDeviceIds: undefined, + fleetCredentials: undefined, + showManagePowerModal: false, + handleManagePowerConfirm: vi.fn(), + handleManagePowerDismiss: vi.fn(), + showCoolingModeModal: false, + coolingModeCount: 0, + currentCoolingMode: undefined, + handleCoolingModeConfirm: vi.fn(), + handleCoolingModeDismiss: vi.fn(), + showAuthenticateFleetModal: false, + authenticationPurpose: null, + showUpdatePasswordModal: false, + hasThirdPartyMiners: false, + handleFleetAuthenticated: vi.fn(), + handlePasswordConfirm: vi.fn(), + handlePasswordDismiss: vi.fn(), + handleAuthDismiss: vi.fn(), + unsupportedMinersInfo: { + visible: false, + unsupportedGroups: [], + totalUnsupportedCount: 0, + noneSupported: false, + }, + handleUnsupportedMinersContinue: vi.fn(), + handleUnsupportedMinersDismiss: vi.fn(), + showManageSecurityModal: false, + minerGroups: [], + handleUpdateGroup: vi.fn(), + handleSecurityModalClose: vi.fn(), + })), + mockBulkActionsPopover: vi.fn( + ({ + actions, + beforeEach: beforeEachAction, + }: { + actions: Array<{ + action: string; + title: string; + actionHandler: () => void; + requiresConfirmation: boolean; + }>; + beforeEach: (requiresConfirmation: boolean) => void; + }) => ( +
+ {actions.map((action) => ( + + ))} +
+ ), + ), + mockListGroupMembers: vi.fn(), + mockFetchAllMinerSnapshots: vi.fn(), + }), +); + +vi.mock("@/protoFleet/features/fleetManagement/components/MinerActionsMenu/useMinerActions", () => ({ + useMinerActions: mockUseMinerActions, +})); + +vi.mock("@/protoFleet/api/fetchAllMinerSnapshots", () => ({ + fetchAllMinerSnapshots: (...args: unknown[]) => mockFetchAllMinerSnapshots(...args), +})); + +vi.mock("@/protoFleet/features/fleetManagement/components/BulkActions", () => ({ + BulkActionsPopover: mockBulkActionsPopover, +})); + +vi.mock("@/protoFleet/features/fleetManagement/components/BulkActions/BulkActionConfirmDialog", () => ({ + default: () => null, +})); + +vi.mock("@/protoFleet/features/fleetManagement/components/BulkActions/UnsupportedMinersModal", () => ({ + default: () => null, +})); + +vi.mock("@/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage", () => ({ + default: () => null, +})); + +vi.mock("@/protoFleet/features/fleetManagement/components/MinerActionsMenu/CoolingModeModal", () => ({ + default: () => null, +})); + +vi.mock("@/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManagePowerModal", () => ({ + default: () => null, +})); + +vi.mock("@/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManageSecurity", () => ({ + ManageSecurityModal: () => null, + UpdateMinerPasswordModal: () => null, +})); + +vi.mock("@/protoFleet/features/auth/components/AuthenticateFleetModal", () => ({ + default: () => null, +})); + +vi.mock("@/protoFleet/api/useDeviceSets", () => ({ + useDeviceSets: () => ({ listGroupMembers: mockListGroupMembers }), +})); + +vi.mock("@/shared/components/Popover", () => ({ + PopoverProvider: ({ children }: { children: ReactNode }) => {children}, + usePopover: () => ({ + triggerRef: { current: null }, + setPopoverRenderMode: vi.fn(), + }), +})); + +vi.mock("@/shared/hooks/useClickOutside", () => ({ + useClickOutside: vi.fn(), +})); + +describe("DeviceSetActionsMenu", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockListGroupMembers.mockImplementation(() => undefined); + mockFetchAllMinerSnapshots.mockResolvedValue({}); + }); + + it("renders 'View group' action when onView is provided", () => { + const onEdit = vi.fn(); + const onView = vi.fn(); + + render(); + + fireEvent.click(screen.getByLabelText("Device set actions")); + + expect(screen.getByTestId("view-group-popover-button")).toBeInTheDocument(); + expect(screen.getByTestId("view-group-popover-button")).toHaveTextContent("View group"); + }); + + it("calls onView when 'View group' is clicked", () => { + const onEdit = vi.fn(); + const onView = vi.fn(); + + render(); + + fireEvent.click(screen.getByLabelText("Device set actions")); + fireEvent.click(screen.getByTestId("view-group-popover-button")); + + expect(onView).toHaveBeenCalledTimes(1); + }); + + it("does not render 'View group' action when onView is not provided", () => { + const onEdit = vi.fn(); + + render(); + + fireEvent.click(screen.getByLabelText("Device set actions")); + + expect(screen.queryByTestId("view-group-popover-button")).not.toBeInTheDocument(); + }); + + it("uses custom viewLabel when provided", () => { + const onEdit = vi.fn(); + const onView = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByLabelText("Device set actions")); + + expect(screen.getByTestId("view-group-popover-button")).toHaveTextContent("View rack"); + }); + + it("shows loading immediately on open when fresh data is required", () => { + mockFetchAllMinerSnapshots.mockReturnValue(new Promise(() => {})); + + const { container } = render(); + + fireEvent.click(screen.getByLabelText("Device set actions")); + + expect(screen.queryByTestId("group-actions-popover")).not.toBeInTheDocument(); + expect(container.querySelector("svg.animate-spin")).not.toBeNull(); + }); + + it("aborts the member-fetch signal on close and creates a fresh signal on reopen", async () => { + mockFetchAllMinerSnapshots.mockReturnValue(new Promise(() => {})); + + render(); + + const button = screen.getByLabelText("Device set actions"); + fireEvent.click(button); + + await waitFor(() => { + expect(mockListGroupMembers).toHaveBeenCalledTimes(1); + }); + + const firstRequest = mockListGroupMembers.mock.calls[0][0] as { signal: AbortSignal }; + expect(firstRequest.signal.aborted).toBe(false); + + fireEvent.click(button); + await waitFor(() => { + expect(firstRequest.signal.aborted).toBe(true); + }); + + fireEvent.click(button); + await waitFor(() => { + expect(mockListGroupMembers).toHaveBeenCalledTimes(2); + }); + + const secondRequest = mockListGroupMembers.mock.calls[1][0] as { signal: AbortSignal }; + expect(firstRequest.signal.aborted).toBe(true); + expect(secondRequest.signal.aborted).toBe(false); + }); + + it("ignores stale callbacks from a prior open", async () => { + const memberRequests: Array<{ + signal: AbortSignal; + onSuccess?: (ids: string[]) => void; + onFinally?: () => void; + }> = []; + + mockListGroupMembers.mockImplementation((request: unknown) => { + memberRequests.push(request as (typeof memberRequests)[number]); + }); + + render(); + + const button = screen.getByLabelText("Device set actions"); + fireEvent.click(button); + + await waitFor(() => { + expect(mockListGroupMembers).toHaveBeenCalledTimes(1); + }); + + fireEvent.click(button); + await waitFor(() => { + expect(memberRequests[0].signal.aborted).toBe(true); + }); + + fireEvent.click(button); + await waitFor(() => { + expect(mockListGroupMembers).toHaveBeenCalledTimes(2); + }); + + act(() => { + memberRequests[0].onSuccess?.(["stale-device"]); + memberRequests[0].onFinally?.(); + }); + + expect(screen.queryByTestId("group-actions-popover")).not.toBeInTheDocument(); + + act(() => { + memberRequests[1].onSuccess?.(["fresh-device"]); + memberRequests[1].onFinally?.(); + }); + + await waitFor(() => { + expect(screen.getByTestId("group-actions-popover")).toBeInTheDocument(); + }); + + // Directly verify the version-counter guard: useMinerActions must never have been + // handed the stale member, and its latest call must reflect the fresh member. + expect(mockUseMinerActions).not.toHaveBeenCalledWith( + expect.objectContaining({ selectedMiners: [{ deviceIdentifier: "stale-device" }] }), + ); + expect(mockUseMinerActions).toHaveBeenLastCalledWith( + expect.objectContaining({ selectedMiners: [{ deviceIdentifier: "fresh-device" }] }), + ); + }); + + it("passes a non-aborted signal to fetchAllMinerSnapshots on open", async () => { + let capturedSignal: AbortSignal | undefined; + mockFetchAllMinerSnapshots.mockImplementation((_filter: unknown, signal?: AbortSignal) => { + capturedSignal = signal; + return new Promise(() => {}); + }); + + render(); + + fireEvent.click(screen.getByLabelText("Device set actions")); + + await waitFor(() => { + expect(mockFetchAllMinerSnapshots).toHaveBeenCalledTimes(1); + }); + + expect(capturedSignal).toBeDefined(); + expect(capturedSignal!.aborted).toBe(false); + }); + + it("aborts the snapshot-fetch signal on close and creates a fresh signal on reopen", async () => { + const signals: AbortSignal[] = []; + mockFetchAllMinerSnapshots.mockImplementation((_filter: unknown, signal?: AbortSignal) => { + if (signal) signals.push(signal); + return new Promise(() => {}); + }); + + render(); + + const button = screen.getByLabelText("Device set actions"); + fireEvent.click(button); + + await waitFor(() => { + expect(signals).toHaveLength(1); + }); + expect(signals[0].aborted).toBe(false); + + fireEvent.click(button); + await waitFor(() => { + expect(signals[0].aborted).toBe(true); + }); + + fireEvent.click(button); + await waitFor(() => { + expect(signals).toHaveLength(2); + }); + + expect(signals[0].aborted).toBe(true); + expect(signals[1].aborted).toBe(false); + }); + + it("ignores stale snapshot resolutions from a prior open", async () => { + type SnapshotResolve = (value: Record) => void; + const resolvers: SnapshotResolve[] = []; + + mockFetchAllMinerSnapshots.mockImplementation(() => { + return new Promise>((resolve) => { + resolvers.push(resolve); + }); + }); + + mockListGroupMembers.mockImplementation( + ({ onSuccess, onFinally }: { onSuccess?: (ids: string[]) => void; onFinally?: () => void }) => { + onSuccess?.(["d1"]); + onFinally?.(); + }, + ); + + render(); + + const button = screen.getByLabelText("Device set actions"); + fireEvent.click(button); + + await waitFor(() => { + expect(resolvers).toHaveLength(1); + }); + + fireEvent.click(button); + fireEvent.click(button); + + await waitFor(() => { + expect(resolvers).toHaveLength(2); + }); + + act(() => { + resolvers[0]({ stale: {} }); + }); + + act(() => { + resolvers[1]({ fresh: {} }); + }); + + await waitFor(() => { + expect(screen.getByTestId("group-actions-popover")).toBeInTheDocument(); + }); + + expect(mockUseMinerActions).toHaveBeenLastCalledWith(expect.objectContaining({ miners: { fresh: {} } })); + }); + + it("does not show spinner after close when deviceSetId becomes undefined", async () => { + mockFetchAllMinerSnapshots.mockReturnValue(new Promise(() => {})); + + const { rerender, container } = render(); + + const button = screen.getByLabelText("Device set actions"); + fireEvent.click(button); + + expect(container.querySelector("svg.animate-spin")).not.toBeNull(); + + fireEvent.click(button); + + rerender(); + + fireEvent.click(screen.getByLabelText("Device set actions")); + + expect(container.querySelector("svg.animate-spin")).toBeNull(); + expect(screen.queryByTestId("group-actions-popover")).toBeInTheDocument(); + }); + + it("passes a rackIds filter to fetchAllMinerSnapshots when deviceSetType is 'rack'", async () => { + let capturedFilter: unknown; + mockFetchAllMinerSnapshots.mockImplementation((filter: unknown) => { + capturedFilter = filter; + return new Promise(() => {}); + }); + + render(); + + fireEvent.click(screen.getByLabelText("Device set actions")); + + await waitFor(() => { + expect(mockFetchAllMinerSnapshots).toHaveBeenCalledTimes(1); + }); + + expect(capturedFilter).toEqual({ rackIds: [7n] }); + }); + + it("aborts and re-fetches when deviceSetId changes while menu is open", async () => { + const snapshotCalls: Array<{ filter: unknown; signal?: AbortSignal }> = []; + mockFetchAllMinerSnapshots.mockImplementation((filter: unknown, signal?: AbortSignal) => { + snapshotCalls.push({ filter, signal }); + return new Promise(() => {}); + }); + + const { rerender } = render(); + + fireEvent.click(screen.getByLabelText("Device set actions")); + + await waitFor(() => { + expect(snapshotCalls).toHaveLength(1); + }); + expect(snapshotCalls[0].filter).toEqual({ groupIds: [1n] }); + expect(snapshotCalls[0].signal?.aborted).toBe(false); + expect(mockListGroupMembers).toHaveBeenCalledTimes(1); + expect(mockListGroupMembers.mock.calls[0][0]).toMatchObject({ deviceSetId: 1n }); + + rerender(); + + await waitFor(() => { + expect(snapshotCalls).toHaveLength(2); + }); + + expect(snapshotCalls[0].signal?.aborted).toBe(true); + expect(snapshotCalls[1].filter).toEqual({ groupIds: [2n] }); + expect(snapshotCalls[1].signal?.aborted).toBe(false); + expect(mockListGroupMembers).toHaveBeenCalledTimes(2); + expect(mockListGroupMembers.mock.calls[1][0]).toMatchObject({ deviceSetId: 2n }); + }); + + it("preserves fetched data across a popover action click (programmatic close)", async () => { + mockFetchAllMinerSnapshots.mockResolvedValueOnce({ + d1: { deviceIdentifier: "d1" }, + d2: { deviceIdentifier: "d2" }, + }); + mockListGroupMembers.mockImplementation( + ({ onSuccess, onFinally }: { onSuccess?: (ids: string[]) => void; onFinally?: () => void }) => { + onSuccess?.(["d1", "d2"]); + onFinally?.(); + }, + ); + + render(); + + fireEvent.click(screen.getByLabelText("Device set actions")); + + await waitFor(() => { + expect(screen.getByTestId("group-actions-popover")).toBeInTheDocument(); + }); + + // Clicking a popover action triggers beforeEach → setIsOpen(false); this is the + // same programmatic-close path used by confirmation/modal flows. The fetched + // members/snapshots must survive so downstream handlers (captured via hook + // closures) see the correct selection rather than an empty one. + fireEvent.click(screen.getByTestId("edit-group-popover-button")); + + expect(mockUseMinerActions).toHaveBeenLastCalledWith( + expect.objectContaining({ + miners: expect.objectContaining({ d1: expect.anything(), d2: expect.anything() }), + selectedMiners: [{ deviceIdentifier: "d1" }, { deviceIdentifier: "d2" }], + }), + ); + }); + + it("clears stale data on close so reopening without deviceSetId shows no stale actions", async () => { + mockFetchAllMinerSnapshots.mockResolvedValueOnce({ + stale1: { deviceIdentifier: "stale1" }, + stale2: { deviceIdentifier: "stale2" }, + }); + mockListGroupMembers.mockImplementation( + ({ onSuccess, onFinally }: { onSuccess?: (ids: string[]) => void; onFinally?: () => void }) => { + onSuccess?.(["stale1", "stale2"]); + onFinally?.(); + }, + ); + + const { rerender } = render(); + + const button = screen.getByLabelText("Device set actions"); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByTestId("group-actions-popover")).toBeInTheDocument(); + }); + + // Confirm the first open surfaced the fetched data to useMinerActions + expect(mockUseMinerActions).toHaveBeenLastCalledWith( + expect.objectContaining({ + miners: expect.objectContaining({ stale1: expect.anything() }), + selectedMiners: [{ deviceIdentifier: "stale1" }, { deviceIdentifier: "stale2" }], + }), + ); + + fireEvent.click(button); + + rerender(); + fireEvent.click(screen.getByLabelText("Device set actions")); + + // After close + reopen without a deviceSetId, the previous fetch's data must not leak + expect(mockUseMinerActions).toHaveBeenLastCalledWith(expect.objectContaining({ miners: {}, selectedMiners: [] })); + }); +}); diff --git a/client/src/protoFleet/features/groupManagement/components/DeviceSetActionsMenu.tsx b/client/src/protoFleet/features/groupManagement/components/DeviceSetActionsMenu.tsx new file mode 100644 index 000000000..540a29091 --- /dev/null +++ b/client/src/protoFleet/features/groupManagement/components/DeviceSetActionsMenu.tsx @@ -0,0 +1,454 @@ +import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { fetchAllMinerSnapshots } from "@/protoFleet/api/fetchAllMinerSnapshots"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { useDeviceSets } from "@/protoFleet/api/useDeviceSets"; +import AuthenticateFleetModal from "@/protoFleet/features/auth/components/AuthenticateFleetModal"; +import PoolSelectionPageWrapper from "@/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage"; +import { BulkActionsPopover } from "@/protoFleet/features/fleetManagement/components/BulkActions"; +import BulkActionConfirmDialog from "@/protoFleet/features/fleetManagement/components/BulkActions/BulkActionConfirmDialog"; +import { type BulkAction } from "@/protoFleet/features/fleetManagement/components/BulkActions/types"; +import UnsupportedMinersModal from "@/protoFleet/features/fleetManagement/components/BulkActions/UnsupportedMinersModal"; +import { + deviceActions, + groupActions, + performanceActions, + settingsActions, + type SupportedAction, +} from "@/protoFleet/features/fleetManagement/components/MinerActionsMenu/constants"; + +type DeviceSetActionType = SupportedAction | "edit-group" | "view-group"; +import CoolingModeModal from "@/protoFleet/features/fleetManagement/components/MinerActionsMenu/CoolingModeModal"; +import ManagePowerModal from "@/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManagePowerModal"; +import { + ManageSecurityModal, + UpdateMinerPasswordModal, +} from "@/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManageSecurity"; +import { useMinerActions } from "@/protoFleet/features/fleetManagement/components/MinerActionsMenu/useMinerActions"; +import { useBatchOperations } from "@/protoFleet/features/fleetManagement/hooks/useBatchOperations"; +import { ArrowRight, Edit, Ellipsis } from "@/shared/assets/icons"; +import { iconSizes } from "@/shared/assets/icons/constants"; +import Button, { type ButtonVariant, sizes, variants } from "@/shared/components/Button"; +import { type SelectionMode } from "@/shared/components/List"; +import { PopoverProvider, usePopover } from "@/shared/components/Popover"; +import ProgressCircular from "@/shared/components/ProgressCircular"; +import { positions } from "@/shared/constants"; +import { useClickOutside } from "@/shared/hooks/useClickOutside"; + +type DeviceSetType = "group" | "rack"; + +interface DeviceSetActionsMenuProps { + memberDeviceIds?: string[]; + deviceSetId?: bigint; + /** Whether this menu is for a group or a rack. Affects the filter used for miner snapshot fetches. */ + deviceSetType?: DeviceSetType; + onEdit: () => void; + /** Label for the edit action in the popover menu (e.g., "Edit group", "Edit rack"). */ + editLabel?: string; + /** Optional callback to navigate to the detail view. When provided, a "View" action is shown. */ + onView?: () => void; + /** Label for the view action in the popover menu (e.g., "View group", "View rack"). */ + viewLabel?: string; + onActionComplete?: () => void; + popoverClassName?: string; + buttonVariant?: ButtonVariant; + /** Ref that exposes the sleep action handler so a parent can trigger it from an external button. */ + sleepActionRef?: RefObject<(() => void) | null>; + /** Ref that reflects whether a bulk-action dialog is currently open. */ + actionActiveRef?: RefObject; +} + +const DeviceSetActionsMenu = (props: DeviceSetActionsMenuProps) => { + return ( + + + + ); +}; + +const DeviceSetActionsMenuInner = ({ + memberDeviceIds: propMemberDeviceIds, + deviceSetId, + deviceSetType = "group", + onEdit, + editLabel = "Edit group", + onView, + viewLabel = "View group", + onActionComplete, + popoverClassName, + buttonVariant = variants.secondary, + sleepActionRef, + actionActiveRef, +}: DeviceSetActionsMenuProps) => { + const { triggerRef, setPopoverRenderMode } = usePopover(); + const batchOps = useBatchOperations(); + const [isOpen, setIsOpen] = useState(false); + + // Lazy-fetched member IDs for table context (when deviceSetId is provided but memberDeviceIds aren't) + const [fetchedMemberIds, setFetchedMemberIds] = useState(null); + const [fetchingMembers, setFetchingMembers] = useState(false); + const { listGroupMembers } = useDeviceSets(); + + // Lazy-fetched miner snapshots for firmware model checks + const [fetchedMiners, setFetchedMiners] = useState>({}); + const [fetchingMiners, setFetchingMiners] = useState(false); + + const fetchVersionRef = useRef(0); + const propMemberDeviceIdsRef = useRef(propMemberDeviceIds); + // Keep the ref in sync with the latest prop without re-running the fetch + // effect when only this prop changes (parents sometimes pass a new array + // reference on every render). + useEffect(() => { + propMemberDeviceIdsRef.current = propMemberDeviceIds; + }, [propMemberDeviceIds]); + + const memberDeviceIds = useMemo( + () => propMemberDeviceIds ?? fetchedMemberIds ?? [], + [propMemberDeviceIds, fetchedMemberIds], + ); + + useEffect(() => { + setPopoverRenderMode("portal-fixed"); + }, [setPopoverRenderMode]); + + const onClickOutside = useCallback(() => { + setIsOpen(false); + }, []); + + useClickOutside({ + ref: triggerRef, + onClickOutside, + ignoreSelectors: [".popover-content"], + }); + + const handleOpen = useCallback(() => { + const opening = !isOpen; + + if (opening) { + if (deviceSetId) { + setFetchedMiners({}); + setFetchingMiners(true); + + if (!propMemberDeviceIds) { + setFetchedMemberIds(null); + setFetchingMembers(true); + } else { + setFetchingMembers(false); + } + } else { + // No deviceSetId: the fetch effect will bail out, so clear any stale + // data from a prior open so the menu does not show a previous group's + // members/snapshots. + setFetchedMemberIds(null); + setFetchedMiners({}); + } + } + + setIsOpen(opening); + }, [isOpen, deviceSetId, propMemberDeviceIds]); + + // Fetch member IDs and miner snapshots when the menu opens. + // Always refetch on open so membership changes are picked up. + // A version counter prevents stale callbacks from updating state after + // the effect re-fires (e.g. close/re-open, deviceSetId change). + useEffect(() => { + if (!isOpen || !deviceSetId) return; + + const version = ++fetchVersionRef.current; + const controller = new AbortController(); + const isCurrent = () => version === fetchVersionRef.current; + + if (!propMemberDeviceIdsRef.current) { + setFetchedMemberIds(null); + setFetchingMembers(true); + listGroupMembers({ + deviceSetId, + signal: controller.signal, + onSuccess: (ids) => { + if (isCurrent()) setFetchedMemberIds(ids); + }, + onFinally: () => { + if (isCurrent()) setFetchingMembers(false); + }, + }); + } else { + setFetchingMembers(false); + } + + const filter = deviceSetType === "rack" ? { rackIds: [deviceSetId] } : { groupIds: [deviceSetId] }; + setFetchedMiners({}); + setFetchingMiners(true); + fetchAllMinerSnapshots(filter, controller.signal) + .then((map) => { + if (isCurrent()) setFetchedMiners(map); + }) + .catch(() => { + // Non-critical — firmware update will show a warning instead + }) + .finally(() => { + if (isCurrent()) setFetchingMiners(false); + }); + + return () => { + // Invalidate version so stale callbacks are rejected. + // Data state (fetchedMemberIds/fetchedMiners) is deliberately preserved + // here so that programmatic closes during confirmation/modal flows do + // not empty the selection that downstream handlers rely on. Stale data + // is cleared in handleOpen when reopening without a deviceSetId. + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional ref mutation in cleanup + ++fetchVersionRef.current; + controller.abort(); + setFetchingMembers(false); + setFetchingMiners(false); + }; + }, [isOpen, deviceSetId, deviceSetType, listGroupMembers]); + + const selectedMinersWithStatus = useMemo( + () => memberDeviceIds.map((id) => ({ deviceIdentifier: id })), + [memberDeviceIds], + ); + + const { + currentAction, + popoverActions, + handleConfirmation, + handleCancel, + handleMiningPoolSuccess, + handleMiningPoolError, + showPoolSelectionPage, + poolFilteredDeviceIds, + fleetCredentials, + showManagePowerModal, + handleManagePowerConfirm, + handleManagePowerDismiss, + showCoolingModeModal, + coolingModeCount, + currentCoolingMode, + handleCoolingModeConfirm, + handleCoolingModeDismiss, + showAuthenticateFleetModal, + authenticationPurpose, + showUpdatePasswordModal, + hasThirdPartyMiners, + handleFleetAuthenticated, + handlePasswordConfirm, + handlePasswordDismiss, + handleAuthDismiss, + unsupportedMinersInfo, + handleUnsupportedMinersContinue, + handleUnsupportedMinersDismiss, + showManageSecurityModal, + minerGroups, + handleUpdateGroup, + handleSecurityModalClose, + } = useMinerActions({ + selectedMiners: selectedMinersWithStatus, + selectionMode: "subset" as SelectionMode, + startBatchOperation: batchOps.startBatchOperation, + completeBatchOperation: batchOps.completeBatchOperation, + removeDevicesFromBatch: batchOps.removeDevicesFromBatch, + miners: fetchedMiners, + onActionComplete, + }); + + // Keep actionActiveRef in sync so the parent can pause polling during action flows + useEffect(() => { + if (actionActiveRef) { + actionActiveRef.current = currentAction !== null; + } + }, [actionActiveRef, currentAction]); + + // Customize actions for group context: + // 1. Filter out "Add to group" (already in a group) + // 2. Insert "Edit group" after the cooling mode divider + const groupPopoverActions = useMemo(() => { + const filtered = popoverActions.filter((a) => a.action !== groupActions.addToGroup); + + const editGroupAction: BulkAction = { + action: "edit-group", + title: editLabel, + icon: , + actionHandler: () => { + setIsOpen(false); + onEdit(); + }, + requiresConfirmation: false, + showGroupDivider: true, + }; + + const viewGroupAction: BulkAction | null = onView + ? { + action: "view-group", + title: viewLabel, + icon: , + actionHandler: () => { + setIsOpen(false); + onView(); + }, + requiresConfirmation: false, + showGroupDivider: false, + } + : null; + + // Insert "Edit group" where the organization section was (after cooling mode's divider) + const coolingModeIndex = filtered.findIndex((a) => a.action === settingsActions.coolingMode); + const withEdit = + coolingModeIndex !== -1 + ? [ + ...filtered.slice(0, coolingModeIndex), + filtered[coolingModeIndex], + editGroupAction, + ...filtered.slice(coolingModeIndex + 1), + ] + : [editGroupAction, ...filtered]; + + return viewGroupAction ? [viewGroupAction, ...withEdit] : withEdit; + }, [popoverActions, onEdit, editLabel, onView, viewLabel]); + + const poolMiners = useMemo(() => { + if (poolFilteredDeviceIds) { + return poolFilteredDeviceIds.map((id) => ({ deviceIdentifier: id })); + } + return selectedMinersWithStatus; + }, [poolFilteredDeviceIds, selectedMinersWithStatus]); + + const [showWarnDialog, setShowWarnDialog] = useState(false); + + // Expose the sleep action handler to the parent via ref + useEffect(() => { + if (!sleepActionRef) return; + const sleepAction = popoverActions.find((a) => a.action === deviceActions.shutdown); + if (sleepAction) { + sleepActionRef.current = () => { + setShowWarnDialog(sleepAction.requiresConfirmation); + sleepAction.actionHandler(); + }; + } else { + sleepActionRef.current = null; + } + }, [sleepActionRef, popoverActions]); + + const handlePopoverAction = useCallback((requiresConfirmation: boolean) => { + setIsOpen(false); + if (requiresConfirmation) { + setShowWarnDialog(true); + } + }, []); + + const handleDialogConfirm = useCallback(() => { + setShowWarnDialog(false); + handleConfirmation(); + }, [handleConfirmation]); + + const handleDialogCancel = useCallback(() => { + setShowWarnDialog(false); + handleCancel(); + }, [handleCancel]); + + // Prevent confirmation dialog flash when continuing from unsupported miners modal + const handleUnsupportedMinersContinueWithReset = useCallback(() => { + setShowWarnDialog(false); + handleUnsupportedMinersContinue(); + }, [handleUnsupportedMinersContinue]); + + return ( + <> +
+
+ + + {/* Confirmation dialogs */} + {groupPopoverActions + .filter((action) => action.requiresConfirmation && action.confirmation) + .map((action) => { + const showDialog = currentAction === action.action && showWarnDialog && !unsupportedMinersInfo.visible; + return ( + + ); + })} + + {/* Modal dialogs */} + + + + + + + + ); +}; + +export default DeviceSetActionsMenu; diff --git a/client/src/protoFleet/features/groupManagement/components/DeviceSetPerformanceSection.tsx b/client/src/protoFleet/features/groupManagement/components/DeviceSetPerformanceSection.tsx new file mode 100644 index 000000000..a68061597 --- /dev/null +++ b/client/src/protoFleet/features/groupManagement/components/DeviceSetPerformanceSection.tsx @@ -0,0 +1,350 @@ +import { useMemo } from "react"; + +import { AggregationType, MeasurementType, type Metric } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import LineChart from "@/protoFleet/components/LineChart"; +import ChartWidget from "@/protoFleet/features/dashboard/components/ChartWidget"; +import { padChartDataWithNulls } from "@/protoFleet/features/dashboard/utils/chartDataPadding"; +import { + normalizeEfficiencyToJTH, + normalizeHashrateToTHs, + normalizePowerToKW, +} from "@/protoFleet/features/dashboard/utils/metricNormalization"; +import { useTemperatureUnit } from "@/protoFleet/store"; +import { FleetDuration } from "@/shared/components/DurationSelector"; +import type { ChartData } from "@/shared/components/LineChart/types"; +import SkeletonBar from "@/shared/components/SkeletonBar"; +import { getDisplayValue } from "@/shared/utils/stringUtils"; +import { convertCtoF, TH_TO_PH_DIVISOR, TH_TO_PH_THRESHOLD } from "@/shared/utils/utility"; + +interface DeviceSetPerformanceSectionProps { + duration: FleetDuration; + /** All metrics for the device set — undefined = not loaded, empty = no data */ + metrics: Metric[] | undefined; +} + +const COLOR_MAP = { + avg: "--color-core-primary-fill", + max: "--color-core-success-fill", + min: "--color-core-warning-fill", +}; + +const ACTIVE_KEYS = ["avg", "max", "min"]; +const TOOLTIP_KEYS = ["avg"]; + +function transformMetrics(metrics: Metric[], normalize: (value: number, deviceCount: number) => number): ChartData[] { + return metrics.map((metric) => { + const findAgg = (type: AggregationType) => + metric.aggregatedValues.find((agg) => agg.aggregationType === type)?.value; + + const deviceCount = metric.deviceCount; + const normalizeOrNull = (v: number | undefined) => (v === undefined ? null : normalize(v, deviceCount)); + return { + datetime: Number(metric.openTime?.seconds ?? 0) * 1000, + avg: normalizeOrNull(findAgg(AggregationType.AVERAGE)), + max: normalizeOrNull(findAgg(AggregationType.MAX)), + min: normalizeOrNull(findAgg(AggregationType.MIN)), + }; + }); +} + +function transformEfficiencyMetrics(metrics: Metric[]): ChartData[] { + return metrics.map((metric) => { + const findAgg = (type: AggregationType) => + metric.aggregatedValues.find((agg) => agg.aggregationType === type)?.value; + + const normalizeOrNull = (v: number | undefined) => (v === undefined ? null : normalizeEfficiencyToJTH(v)); + return { + datetime: Number(metric.openTime?.seconds ?? 0) * 1000, + avg: normalizeOrNull(findAgg(AggregationType.AVERAGE)), + max: normalizeOrNull(findAgg(AggregationType.MAX)), + min: normalizeOrNull(findAgg(AggregationType.MIN)), + }; + }); +} + +function transformTemperatureMetrics(metrics: Metric[]): ChartData[] { + return metrics.map((metric) => { + const findAgg = (type: AggregationType) => + metric.aggregatedValues.find((agg) => agg.aggregationType === type)?.value; + + return { + datetime: Number(metric.openTime?.seconds ?? 0) * 1000, + avg: findAgg(AggregationType.AVERAGE) ?? null, + max: findAgg(AggregationType.MAX) ?? null, + min: findAgg(AggregationType.MIN) ?? null, + }; + }); +} + +function computeReferenceLines(chartData: ChartData[]): { value: number; color: string; strokeDasharray: string }[] { + const avgValues: number[] = []; + for (const d of chartData) { + if (typeof d.avg === "number") avgValues.push(d.avg); + } + if (avgValues.length === 0) return []; + return [ + { value: Math.min(...avgValues), color: "--color-intent-critical-fill", strokeDasharray: "1 6" }, + { value: Math.max(...avgValues), color: "--color-core-primary-50", strokeDasharray: "1 6" }, + ]; +} + +function ChartPanel({ + label, + metrics, + units, + duration, + transform, + formatStat, +}: { + label: string; + metrics: Metric[] | undefined; + units: string; + duration: FleetDuration; + transform: (metrics: Metric[]) => { chartData: ChartData[]; units: string }; + formatStat: (data: ChartData[], units: string) => { value: string; units: string }; +}) { + const { chartData, displayUnits } = useMemo(() => { + if (metrics === undefined) return { chartData: undefined, displayUnits: units }; + if (metrics.length === 0) return { chartData: null, displayUnits: units }; + + const result = transform(metrics); + return { + chartData: padChartDataWithNulls(result.chartData, duration), + displayUnits: result.units, + }; + }, [metrics, duration, transform, units]); + + const referenceLines = useMemo(() => { + if (!chartData?.length) return undefined; + return computeReferenceLines(chartData); + }, [chartData]); + + const legendStats = useMemo(() => { + if (!chartData?.length) return null; + const avgValues: number[] = []; + for (const d of chartData) { + if (typeof d.avg === "number") avgValues.push(d.avg); + } + if (avgValues.length === 0) return null; + const current = avgValues[avgValues.length - 1]; + const max = Math.max(...avgValues); + const min = Math.min(...avgValues); + const fmt = (v: number) => `${Number(v.toFixed(1))} ${displayUnits}`; + return { current: fmt(current), max: fmt(max), min: fmt(min) }; + }, [chartData, displayUnits]); + + if (metrics === undefined) { + return ( + + + + ); + } + + if (!chartData || chartData.length === 0) { + return {null}; + } + + const statDisplay = formatStat(chartData, displayUnits); + + return ( + +
+ + {legendStats && ( +
+
+ + + + {legendStats.current} +
+
+ + + + {legendStats.max} +
+
+ + + + {legendStats.min} +
+
+ )} +
+
+ ); +} + +export function DeviceSetPerformanceSection({ duration, metrics: allMetrics }: DeviceSetPerformanceSectionProps) { + const temperatureUnit = useTemperatureUnit(); + const isFahrenheit = temperatureUnit === "F"; + + // Filter metrics by measurement type — mirrors what usePanelMetrics did from the store + const hashrateMetrics = useMemo( + () => allMetrics?.filter((m) => m.measurementType === MeasurementType.HASHRATE), + [allMetrics], + ); + const temperatureMetrics = useMemo( + () => allMetrics?.filter((m) => m.measurementType === MeasurementType.TEMPERATURE), + [allMetrics], + ); + const efficiencyMetrics = useMemo( + () => allMetrics?.filter((m) => m.measurementType === MeasurementType.EFFICIENCY), + [allMetrics], + ); + const powerMetrics = useMemo( + () => allMetrics?.filter((m) => m.measurementType === MeasurementType.POWER), + [allMetrics], + ); + const hashrateTransform = useMemo( + () => (metrics: Metric[]) => { + const chartData = transformMetrics(metrics, normalizeHashrateToTHs); + const maxValue = Math.max(...chartData.map((d) => d.avg ?? 0)); + if (maxValue > TH_TO_PH_THRESHOLD) { + return { + chartData: chartData.map((d) => ({ + ...d, + avg: d.avg !== null ? d.avg / TH_TO_PH_DIVISOR : null, + max: d.max !== null ? d.max / TH_TO_PH_DIVISOR : null, + min: d.min !== null ? d.min / TH_TO_PH_DIVISOR : null, + })), + units: "PH/S", + }; + } + return { chartData, units: "TH/S" }; + }, + [], + ); + + const temperatureTransform = useMemo( + () => (metrics: Metric[]) => { + const chartData = transformTemperatureMetrics(metrics); + if (isFahrenheit) { + return { + chartData: chartData.map((d) => ({ + ...d, + avg: d.avg !== null ? convertCtoF(d.avg) : null, + max: d.max !== null ? convertCtoF(d.max) : null, + min: d.min !== null ? convertCtoF(d.min) : null, + })), + units: "°F", + }; + } + return { chartData, units: "°C" }; + }, + [isFahrenheit], + ); + + const efficiencyTransform = useMemo( + () => (metrics: Metric[]) => ({ chartData: transformEfficiencyMetrics(metrics), units: "J/TH" }), + [], + ); + + const powerTransform = useMemo( + () => (metrics: Metric[]) => ({ chartData: transformMetrics(metrics, normalizePowerToKW), units: "kW" }), + [], + ); + + const defaultFormatStat = useMemo( + () => (data: ChartData[], units: string) => { + const last = data[data.length - 1]; + const value = last?.avg; + return { value: value !== null && value !== undefined ? Number(value).toFixed(1) : "N/A", units }; + }, + [], + ); + + const temperatureFormatStat = useMemo( + () => (data: ChartData[], units: string) => { + const last = data[data.length - 1]; + const min = last?.min; + const max = last?.max; + if (min === null || min === undefined || max === null || max === undefined) { + return { value: "N/A", units: "" }; + } + const minFormatted = `${getDisplayValue(Number(min))} ${units}`; + const maxFormatted = `${getDisplayValue(Number(max))} ${units}`; + return { value: `${minFormatted} – ${maxFormatted}`, units: "" }; + }, + [], + ); + + return ( +
+ + + + +
+ ); +} diff --git a/client/src/protoFleet/features/groupManagement/components/GroupModal.stories.tsx b/client/src/protoFleet/features/groupManagement/components/GroupModal.stories.tsx new file mode 100644 index 000000000..49dfb8b98 --- /dev/null +++ b/client/src/protoFleet/features/groupManagement/components/GroupModal.stories.tsx @@ -0,0 +1,32 @@ +import { useState } from "react"; +import { action } from "storybook/actions"; +import GroupModal from "./GroupModal"; + +export default { + title: "Proto Fleet/Group Management/GroupModal", + component: GroupModal, +}; + +export const CreateNew = () => { + const [show, setShow] = useState(true); + + return ( + <> + {!show && ( +
+ +
+ )} + { + action("onDismiss")(); + setShow(false); + }} + onSuccess={() => action("onSuccess")()} + /> + + ); +}; diff --git a/client/src/protoFleet/features/groupManagement/components/GroupModal.tsx b/client/src/protoFleet/features/groupManagement/components/GroupModal.tsx new file mode 100644 index 000000000..37fc511dd --- /dev/null +++ b/client/src/protoFleet/features/groupManagement/components/GroupModal.tsx @@ -0,0 +1,234 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import type { DeviceSet } from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; +import { useDeviceSets } from "@/protoFleet/api/useDeviceSets"; +import MinerSelectionList, { type MinerSelectionListHandle } from "@/protoFleet/components/MinerSelectionList"; + +import { Alert } from "@/shared/assets/icons"; +import { variants } from "@/shared/components/Button"; +import Callout from "@/shared/components/Callout"; +import Dialog from "@/shared/components/Dialog"; +import Input from "@/shared/components/Input"; +import Modal from "@/shared/components/Modal"; +import { pushToast, STATUSES } from "@/shared/features/toaster"; + +interface GroupModalProps { + show: boolean; + onDismiss: () => void; + onSuccess: () => void; + group?: DeviceSet; +} + +const GroupModal = ({ show, onDismiss, onSuccess, group }: GroupModalProps) => { + const isEditMode = Boolean(group); + const { createGroup, updateGroup, deleteGroup, listGroupMembers } = useDeviceSets(); + const [groupName, setGroupName] = useState(group?.label ?? ""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [errorMsg, setErrorMsg] = useState(""); + const [isMembersLoading, setIsMembersLoading] = useState(isEditMode); + const [existingMemberIds, setExistingMemberIds] = useState([]); + + const selectionRef = useRef(null); + + // Pre-load existing members in edit mode + useEffect(() => { + if (!group) return; + listGroupMembers({ + deviceSetId: group.id, + onSuccess: (identifiers) => { + setExistingMemberIds(identifiers); + }, + onError: (error) => { + setErrorMsg(error || "Failed to load group members. Please close and try again."); + }, + onFinally: () => { + setIsMembersLoading(false); + }, + }); + }, [group, listGroupMembers]); + + const handleSave = useCallback( + (selection: { selectedItems: string[]; allSelected: boolean }) => { + const { selectedItems, allSelected } = selection; + + setIsSubmitting(true); + setErrorMsg(""); + + if (isEditMode && group) { + updateGroup({ + deviceSetId: group.id, + label: groupName.trim(), + ...(allSelected ? { allDevices: true } : { deviceIdentifiers: selectedItems }), + onSuccess: () => { + pushToast({ + message: `Group "${groupName.trim()}" updated`, + status: STATUSES.success, + }); + onSuccess(); + onDismiss(); + }, + onError: (error) => { + setErrorMsg(error || "Failed to update group. Please try again."); + }, + onFinally: () => { + setIsSubmitting(false); + }, + }); + } else { + createGroup({ + label: groupName.trim(), + ...(allSelected ? { allDevices: true } : { deviceIdentifiers: selectedItems }), + onSuccess: () => { + pushToast({ + message: `Group "${groupName.trim()}" created`, + status: STATUSES.success, + }); + onSuccess(); + onDismiss(); + }, + onError: (error) => { + setErrorMsg(error || "Failed to create group. Please try again."); + }, + onFinally: () => { + setIsSubmitting(false); + }, + }); + } + }, + [groupName, isEditMode, group, createGroup, updateGroup, onSuccess, onDismiss], + ); + + const handleDelete = useCallback(() => { + if (!group) return; + + setIsDeleting(true); + deleteGroup({ + deviceSetId: group.id, + onSuccess: () => { + pushToast({ + message: `Group "${group.label}" deleted`, + status: STATUSES.success, + }); + onSuccess(); + onDismiss(); + }, + onError: (error) => { + setShowDeleteConfirm(false); + setErrorMsg(error || "Failed to delete group. Please try again."); + }, + onFinally: () => { + setIsDeleting(false); + }, + }); + }, [group, deleteGroup, onSuccess, onDismiss]); + + const handleSaveClick = useCallback(() => { + if (!groupName.trim()) { + setErrorMsg("Group name is required"); + return; + } + const selection = selectionRef.current?.getSelection(); + if (!selection) return; + const { selectedItems, allSelected } = selection; + if (!allSelected && selectedItems.length === 0) { + setErrorMsg("Select at least one miner"); + return; + } + handleSave({ selectedItems, allSelected }); + }, [groupName, handleSave]); + + if (show === false) return null; + + return ( + <> + setShowDeleteConfirm(true), + variant: variants.secondaryDanger, + dismissModalOnClick: false, + }, + ] + : []), + { + text: "Save", + onClick: handleSaveClick, + variant: variants.primary, + loading: isSubmitting, + disabled: isMembersLoading, + dismissModalOnClick: false, + }, + ]} + divider={false} + title={isEditMode ? "Edit group" : "Add group"} + description={ + isEditMode ? "Rename your group or update its miners." : "Name your group and assign miners to it." + } + > +
+ {errorMsg ? ( + } + testId="error-msg" + title={errorMsg} + /> + ) : null} + +
+ { + setGroupName(value); + setErrorMsg(""); + }} + /> +
+ + +
+
+ + {showDeleteConfirm && group && ( + setShowDeleteConfirm(false)} + buttons={[ + { + text: "Cancel", + onClick: () => setShowDeleteConfirm(false), + variant: variants.secondary, + }, + { + text: "Delete", + onClick: handleDelete, + variant: variants.danger, + loading: isDeleting, + }, + ]} + /> + )} + + ); +}; + +export default GroupModal; diff --git a/client/src/protoFleet/features/groupManagement/components/GroupsTable/GroupNameCell.tsx b/client/src/protoFleet/features/groupManagement/components/GroupsTable/GroupNameCell.tsx new file mode 100644 index 000000000..4b346dfda --- /dev/null +++ b/client/src/protoFleet/features/groupManagement/components/GroupsTable/GroupNameCell.tsx @@ -0,0 +1,36 @@ +import { Link, useNavigate } from "react-router-dom"; + +import type { DeviceSet } from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; +import DeviceSetActionsMenu from "@/protoFleet/features/groupManagement/components/DeviceSetActionsMenu"; +import { variants } from "@/shared/components/Button"; + +type GroupNameCellProps = { + group: DeviceSet; + onEdit: (group: DeviceSet) => void; + onActionComplete?: () => void; +}; + +const GroupNameCell = ({ group, onEdit, onActionComplete }: GroupNameCellProps) => { + const navigate = useNavigate(); + + return ( +
+ + {group.label} + + onEdit(group)} + onView={() => navigate(`/groups/${encodeURIComponent(group.label)}`)} + onActionComplete={onActionComplete} + buttonVariant={variants.textOnly} + /> +
+ ); +}; + +export default GroupNameCell; diff --git a/client/src/protoFleet/features/groupManagement/components/GroupsTable/index.ts b/client/src/protoFleet/features/groupManagement/components/GroupsTable/index.ts new file mode 100644 index 000000000..65a808fe2 --- /dev/null +++ b/client/src/protoFleet/features/groupManagement/components/GroupsTable/index.ts @@ -0,0 +1 @@ +export { default as GroupNameCell } from "./GroupNameCell"; diff --git a/client/src/protoFleet/features/groupManagement/index.ts b/client/src/protoFleet/features/groupManagement/index.ts new file mode 100644 index 000000000..be510216c --- /dev/null +++ b/client/src/protoFleet/features/groupManagement/index.ts @@ -0,0 +1,2 @@ +export { default as GroupOverviewPage } from "./pages/GroupOverviewPage"; +export { default as GroupsPage } from "./pages/GroupsPage"; diff --git a/client/src/protoFleet/features/groupManagement/pages/GroupOverviewPage.tsx b/client/src/protoFleet/features/groupManagement/pages/GroupOverviewPage.tsx new file mode 100644 index 000000000..fa5ee1606 --- /dev/null +++ b/client/src/protoFleet/features/groupManagement/pages/GroupOverviewPage.tsx @@ -0,0 +1,331 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useParams } from "react-router-dom"; + +import type { DeviceSet } from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; +import { AggregationType, MeasurementType } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { useComponentErrors } from "@/protoFleet/api/useComponentErrors"; +import { useDeviceSets } from "@/protoFleet/api/useDeviceSets"; +import { useDeviceSetStateCounts } from "@/protoFleet/api/useDeviceSetStateCounts"; +import { useTelemetryMetrics } from "@/protoFleet/api/useTelemetryMetrics"; +import { POLL_INTERVAL_MS } from "@/protoFleet/constants/polling"; +import FleetHealth from "@/protoFleet/features/dashboard/components/FleetHealth"; +import DeviceSetActionsMenu from "@/protoFleet/features/groupManagement/components/DeviceSetActionsMenu"; +import { DeviceSetPerformanceSection } from "@/protoFleet/features/groupManagement/components/DeviceSetPerformanceSection"; +import GroupModal from "@/protoFleet/features/groupManagement/components/GroupModal"; +import FleetErrors from "@/protoFleet/features/kpis/components/FleetErrors"; +import { useDuration, useSetDuration } from "@/protoFleet/store"; +import { ChevronDown } from "@/shared/assets/icons"; +import Button, { variants } from "@/shared/components/Button"; +import DurationSelector, { fleetDurations } from "@/shared/components/DurationSelector"; +import Header from "@/shared/components/Header"; +import ProgressCircular from "@/shared/components/ProgressCircular"; +import { useNavigate } from "@/shared/hooks/useNavigate"; +import { useStickyState } from "@/shared/hooks/useStickyState"; + +const ALL_MEASUREMENT_TYPES: MeasurementType[] = [ + MeasurementType.HASHRATE, + MeasurementType.POWER, + MeasurementType.TEMPERATURE, + MeasurementType.EFFICIENCY, + MeasurementType.UPTIME, +]; + +const ALL_AGGREGATION_TYPES: AggregationType[] = [AggregationType.AVERAGE, AggregationType.MIN, AggregationType.MAX]; + +const GroupOverviewPage = () => { + const { groupLabel } = useParams<{ groupLabel: string }>(); + const label = groupLabel ?? ""; + const navigate = useNavigate(); + + // Group resolution state + const [group, setGroup] = useState(null); + const [memberDeviceIds, setMemberDeviceIds] = useState(null); + const [loading, setLoading] = useState(true); + const [notFound, setNotFound] = useState(false); + const [resolveError, setResolveError] = useState(null); + const [showEditModal, setShowEditModal] = useState(false); + + const { listGroups, listGroupMembers } = useDeviceSets(); + + // Request versioning to guard against stale resolution callbacks + const resolveVersionRef = useRef(0); + + // Resolve a group by label (or by ID if provided) → set group + member device IDs + const resolveGroup = useCallback( + (resolveLabel: string, groupId?: bigint) => { + const version = ++resolveVersionRef.current; + setLoading(true); + setGroup(null); + setMemberDeviceIds(null); + setNotFound(false); + setResolveError(null); + + listGroups({ + onSuccess: (deviceSets) => { + if (version !== resolveVersionRef.current) return; + const match = groupId + ? deviceSets.find((c) => c.id === groupId) + : deviceSets.find((c) => c.label === resolveLabel); + if (!match) { + setNotFound(true); + setLoading(false); + return; + } + setGroup(match); + // If the label changed (e.g., after edit), navigate to the new URL + if (match.label !== resolveLabel) { + navigate(`/groups/${encodeURIComponent(match.label)}`); + return; + } + listGroupMembers({ + deviceSetId: match.id, + onSuccess: (deviceIdentifiers) => { + if (version !== resolveVersionRef.current) return; + setMemberDeviceIds(deviceIdentifiers); + setLoading(false); + }, + onError: (msg) => { + if (version !== resolveVersionRef.current) return; + setResolveError(msg); + setLoading(false); + }, + }); + }, + onError: (msg) => { + if (version !== resolveVersionRef.current) return; + setResolveError(msg); + setLoading(false); + }, + }); + }, + [listGroups, listGroupMembers, navigate], + ); + + // Resolve group label → group object → device IDs + useEffect(() => { + if (!label) { + setNotFound(true); + setLoading(false); + return; + } + + setLoading(true); + resolveGroup(label); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [label]); + + const duration = useDuration(); + const setDuration = useSetDuration(); + const { refs } = useStickyState(); + + // Component errors scoped to group's devices + // Pass undefined when no members yet (loading); pass empty array for truly empty groups + // so useComponentErrors can distinguish "no scope" from "empty scope" + const componentErrorsOptions = useMemo( + () => (memberDeviceIds ? { deviceIdentifiers: memberDeviceIds, pollIntervalMs: POLL_INTERVAL_MS } : undefined), + [memberDeviceIds], + ); + const { controlBoardErrors, fanErrors, hashboardErrors, psuErrors } = useComponentErrors(componentErrorsOptions); + + // Group size for "X of Y miners reporting" subtitles + const groupSize = memberDeviceIds?.length ?? 0; + + // Scoped state counts via getDeviceSetStats API + const { + totalMiners, + stateCounts, + hasLoaded: statsLoaded, + refetch: refetchStats, + } = useDeviceSetStateCounts({ + deviceSetId: group?.id, + pollIntervalMs: POLL_INTERVAL_MS, + }); + + const isEmptyGroup = memberDeviceIds !== null && memberDeviceIds.length === 0; + + // Telemetry fetching - scoped to group's device IDs, polled + const telemetryEnabled = memberDeviceIds !== null && memberDeviceIds.length > 0; + + const telemetryOptions = useMemo( + () => ({ + deviceIds: memberDeviceIds ?? [], + measurementTypes: ALL_MEASUREMENT_TYPES, + aggregations: ALL_AGGREGATION_TYPES, + duration, + enabled: telemetryEnabled, + pollIntervalMs: POLL_INTERVAL_MS, + }), + [memberDeviceIds, duration, telemetryEnabled], + ); + + const { data: telemetryData } = useTelemetryMetrics(telemetryOptions); + + // For empty groups, treat as "loaded with no data" so panels show "No data" not skeleton + const metrics = isEmptyGroup ? [] : telemetryData?.metrics; + + if (loading) { + return ( +
+ +
+ ); + } + + if (notFound) { + return ( +
+

Group not found

+

No group with the label “{label}” exists.

+
+ ); + } + + if (resolveError) { + return ( +
+

Error loading group

+

{resolveError}

+
+ ); + } + + return ( +
+
+ {/* Header */} +
+
} + iconAriaLabel="Back to groups" + iconOnClick={() => navigate("/groups")} + > +
+ + + setShowEditModal(true)} + onActionComplete={() => { + resolveGroup(label, group?.id); + void refetchStats(); + }} + /> +
+
+
+ + {/* Overview Section */} +
+
+ + +
+
+ + {/* Performance Section */} +
+
+
+
+
Performance
+
+
+ + + + Group +
+
+ + + + Max +
+
+ + + + Min +
+
+
+ +
+
+
+ +
+ +
+
+
+
+ + {showEditModal && group && ( + setShowEditModal(false)} + onSuccess={() => { + setShowEditModal(false); + resolveGroup(label, group.id); + void refetchStats(); + }} + /> + )} +
+ ); +}; + +export default GroupOverviewPage; diff --git a/client/src/protoFleet/features/groupManagement/pages/GroupsPage.tsx b/client/src/protoFleet/features/groupManagement/pages/GroupsPage.tsx new file mode 100644 index 000000000..3f1a4b30d --- /dev/null +++ b/client/src/protoFleet/features/groupManagement/pages/GroupsPage.tsx @@ -0,0 +1,244 @@ +import { type ReactNode, useCallback, useMemo, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; + +import type { DeviceSet } from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; +import { useDeviceSets } from "@/protoFleet/api/useDeviceSets"; +import { + DeviceSetList, + type DeviceSetListItem, + issueOptions, + useIssueFilter, +} from "@/protoFleet/components/DeviceSetList"; +import NoFilterResultsEmptyState from "@/protoFleet/components/NoFilterResultsEmptyState"; +import GroupModal from "@/protoFleet/features/groupManagement/components/GroupModal"; +import GroupNameCell from "@/protoFleet/features/groupManagement/components/GroupsTable/GroupNameCell"; +import { useDeviceSetListState } from "@/protoFleet/hooks/useDeviceSetListState"; + +import { Alert, DismissTiny, Groups } from "@/shared/assets/icons"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import Callout from "@/shared/components/Callout"; +import Header from "@/shared/components/Header"; +import DropdownFilter from "@/shared/components/List/Filters/DropdownFilter"; +import ProgressCircular from "@/shared/components/ProgressCircular"; + +const GROUPS_PAGE_SIZE = 50; + +const GroupsPage = () => { + const navigate = useNavigate(); + const { listGroups } = useDeviceSets(); + const [showGroupModal, setShowGroupModal] = useState(false); + const [editGroup, setEditGroup] = useState(null); + const [selectedIssues, setSelectedIssues] = useState([]); + + const { selectedIssuesRef, getErrorComponentTypes } = useIssueFilter(); + + const { + deviceSets: groups, + statsMap, + isLoading, + hasEverLoaded, + error, + currentSort, + currentPage, + hasNextPage, + totalCount, + handleSort, + handleNextPage, + handlePrevPage, + resetAndFetch, + } = useDeviceSetListState(listGroups, GROUPS_PAGE_SIZE, getErrorComponentTypes); + + const handleIssuesChange = useCallback( + (issues: string[]) => { + setSelectedIssues(issues); + selectedIssuesRef.current = issues; + resetAndFetch(); + }, + [resetAndFetch, selectedIssuesRef], + ); + + const handleRemoveIssue = useCallback( + (issueId: string) => { + const next = selectedIssues.filter((id) => id !== issueId); + setSelectedIssues(next); + selectedIssuesRef.current = next; + resetAndFetch(); + }, + [selectedIssues, resetAndFetch, selectedIssuesRef], + ); + + const activeFilterPills = useMemo(() => { + return selectedIssues + .map((issueId) => { + const issue = issueOptions.find((o) => o.id === issueId); + if (!issue) return null; + return { key: `issue-${issueId}`, label: issue.label, onRemove: () => handleRemoveIssue(issueId) }; + }) + .filter(Boolean) as { key: string; label: string; onRemove: () => void }[]; + }, [selectedIssues, handleRemoveIssue]); + + const hasActiveFilters = selectedIssues.length > 0; + + const handleClearFilters = useCallback(() => { + setSelectedIssues([]); + selectedIssuesRef.current = []; + resetAndFetch(); + }, [resetAndFetch, selectedIssuesRef]); + + const emptyStateRow: ReactNode = useMemo(() => { + if (isLoading || totalCount > 0) return undefined; + return ; + }, [hasActiveFilters, isLoading, totalCount, handleClearFilters]); + + const renderName = useCallback( + (item: DeviceSetListItem) => ( + + ), + [resetAndFetch], + ); + + const handleRowClick = useCallback( + (item: DeviceSetListItem) => { + navigate(`/groups/${encodeURIComponent(item.deviceSet.label)}`); + }, + [navigate], + ); + + const renderMiners = useCallback( + (item: DeviceSetListItem) => ( + + {item.deviceSet.deviceCount} + + ), + [], + ); + + if (isLoading && !hasEverLoaded) { + return ( +
+ +
+ ); + } + + if (error && !hasEverLoaded) { + return ( +
+

{error}

+
+ ); + } + + const hasGroups = groups.length > 0 || hasEverLoaded; + + return ( + <> + {!hasGroups ? ( +
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ ) : ( + <> +
+

Groups

+
+
+
+ +
+
+ +
+
+ {activeFilterPills.length > 0 && ( +
+ {activeFilterPills.map((pill) => ( + + ))} +
+ )} +
+
+ {error ? ( + } + title={error} + /> + ) : null} +
+ 0} + hasNextPage={hasNextPage} + onNextPage={handleNextPage} + onPrevPage={handlePrevPage} + onRowClick={handleRowClick} + emptyStateRow={emptyStateRow} + /> +
+ + )} + + {showGroupModal && ( + setShowGroupModal(false)} onSuccess={resetAndFetch} /> + )} + + {editGroup && ( + setEditGroup(null)} + onSuccess={resetAndFetch} + /> + )} + + ); +}; + +export default GroupsPage; diff --git a/client/src/protoFleet/features/kpis/components/ComponentErrors/ComponentErrors.stories.tsx b/client/src/protoFleet/features/kpis/components/ComponentErrors/ComponentErrors.stories.tsx new file mode 100644 index 000000000..95c19b8d5 --- /dev/null +++ b/client/src/protoFleet/features/kpis/components/ComponentErrors/ComponentErrors.stories.tsx @@ -0,0 +1,146 @@ +import { BrowserRouter } from "react-router-dom"; +import type { Meta, StoryObj } from "@storybook/react"; +import ComponentErrors from "./ComponentErrors"; +import ControlBoard from "@/shared/assets/icons/ControlBoard"; +import Fan from "@/shared/assets/icons/Fan"; +import Hashboard from "@/shared/assets/icons/Hashboard"; +import LightningAlt from "@/shared/assets/icons/LightningAlt"; + +const meta: Meta = { + title: "Proto Fleet/Dashboard/ComponentErrors", + component: ComponentErrors, + parameters: { + withRouter: false, + layout: "centered", + docs: { + description: { + component: "Displays component-level error status for fleet hardware with icon and status message", + }, + }, + }, + tags: ["autodocs"], + argTypes: { + icon: { + control: false, + description: "Icon component representing the hardware type", + }, + heading: { + control: "text", + description: "The hardware component name", + }, + errorCount: { + control: "number", + description: "Number of miners with errors (0 displays 'No issues', undefined shows loading state)", + }, + href: { + control: "text", + description: "Optional link destination (renders as Link when provided)", + }, + className: { + control: "text", + description: "Optional CSS classes for styling", + }, + }, + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + icon: , + heading: "Control Boards", + errorCount: 0, + }, +}; + +export const Loading: Story = { + args: { + icon: , + heading: "Control Boards", + errorCount: undefined, + }, +}; + +export const WithErrors: Story = { + args: { + icon: , + heading: "Control Boards", + errorCount: 2, + }, +}; + +export const HashboardNoIssues: Story = { + args: { + icon: , + heading: "Hashboards", + errorCount: 0, + }, +}; + +export const HashboardErrors: Story = { + args: { + icon: , + heading: "Hashboards", + errorCount: 5, + }, +}; + +export const PSUNoIssues: Story = { + args: { + icon: , + heading: "Power Supplies", + errorCount: 0, + }, +}; + +export const PSUErrors: Story = { + args: { + icon: , + heading: "Power Supplies", + errorCount: 1, + }, +}; + +export const FanNoIssues: Story = { + args: { + icon: , + heading: "Fans", + errorCount: 0, + }, +}; + +export const FanErrors: Story = { + args: { + icon: , + heading: "Fans", + errorCount: 42, + }, +}; + +export const WithLink: Story = { + args: { + icon: , + heading: "Control Boards", + errorCount: 3, + href: "/errors/control-boards", + }, +}; + +export const NoErrorsWithLink: Story = { + args: { + icon: , + heading: "Hashboards", + errorCount: 0, + href: "/errors/hashboards", + }, +}; diff --git a/client/src/protoFleet/features/kpis/components/ComponentErrors/ComponentErrors.test.tsx b/client/src/protoFleet/features/kpis/components/ComponentErrors/ComponentErrors.test.tsx new file mode 100644 index 000000000..a2b6a483a --- /dev/null +++ b/client/src/protoFleet/features/kpis/components/ComponentErrors/ComponentErrors.test.tsx @@ -0,0 +1,71 @@ +import { BrowserRouter } from "react-router-dom"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import ComponentErrors from "./ComponentErrors"; + +describe("ComponentErrors", () => { + it("renders heading with no issues when errorCount is 0", () => { + render(Icon
} heading="Control Boards" errorCount={0} />); + + expect(screen.getByText("Control Boards")).toBeInTheDocument(); + expect(screen.getByText("No issues")).toBeInTheDocument(); + }); + + it("renders icon correctly", () => { + render( + Test Icon
} heading="Test Heading" errorCount={0} />, + ); + + expect(screen.getByTestId("test-icon")).toBeInTheDocument(); + expect(screen.getByText("Test Icon")).toBeInTheDocument(); + }); + + it("applies custom className", () => { + const { container } = render( + Icon
} heading="Test" errorCount={0} className="custom-class" />, + ); + + const componentErrors = container.firstChild as HTMLElement; + expect(componentErrors).toHaveClass("custom-class"); + }); + + it("renders correct message for single miner error", () => { + render(Icon
} heading="Fans" errorCount={1} />); + + expect(screen.getByText("Fans")).toBeInTheDocument(); + expect(screen.getByText("1 miner needs attention")).toBeInTheDocument(); + }); + + it("renders correct message for multiple miner errors", () => { + render(Icon
} heading="Hashboards" errorCount={5} />); + + expect(screen.getByText("Hashboards")).toBeInTheDocument(); + expect(screen.getByText("5 miners need attention")).toBeInTheDocument(); + }); + + it("renders skeleton loader when errorCount is undefined", () => { + render(Icon
} heading="Control Boards" errorCount={undefined} />); + + expect(screen.getByText("Control Boards")).toBeInTheDocument(); + expect(screen.getByTestId("skeleton-bar")).toBeInTheDocument(); + }); + + it("renders as a div when href is not provided", () => { + const { container } = render(Icon
} heading="Control Boards" errorCount={0} />); + + const element = container.firstChild as HTMLElement; + expect(element.tagName).toBe("DIV"); + }); + + it("renders as a Link when href is provided", () => { + render( + + Icon
} heading="Control Boards" errorCount={2} href="/errors/control-boards" /> + , + ); + + const link = screen.getByRole("link"); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute("href", "/errors/control-boards"); + }); +}); diff --git a/client/src/protoFleet/features/kpis/components/ComponentErrors/ComponentErrors.tsx b/client/src/protoFleet/features/kpis/components/ComponentErrors/ComponentErrors.tsx new file mode 100644 index 000000000..efbc55011 --- /dev/null +++ b/client/src/protoFleet/features/kpis/components/ComponentErrors/ComponentErrors.tsx @@ -0,0 +1,68 @@ +import { ReactNode } from "react"; +import { Link } from "react-router-dom"; +import clsx from "clsx"; +import SkeletonBar from "@/shared/components/SkeletonBar"; + +type ComponentErrorsProps = { + icon: ReactNode; + heading: string; + errorCount?: number; + href?: string; + className?: string; +}; + +const ComponentErrors = ({ icon, heading, errorCount, href, className }: ComponentErrorsProps) => { + const isLoading = errorCount === undefined; + + let statusText = ""; + if (errorCount === 0) { + statusText = "No issues"; + } else if (errorCount === 1) { + statusText = "1 miner needs attention"; + } else if (errorCount !== undefined) { + statusText = `${errorCount} miners need attention`; + } + + const content = ( + <> +
0 + ? "bg-intent-critical-fill text-text-contrast" + : "bg-surface-5 text-text-primary-70 dark:bg-core-primary-5", + )} + > + {icon} +
+
+
{heading}
+ {isLoading ? ( + + ) : ( +
{statusText}
+ )} +
+ + ); + + const isClickable = href && errorCount && errorCount > 0; + + const baseClassName = clsx( + "flex items-center gap-3 rounded-xl bg-surface-base dark:bg-core-primary-5 p-4", + isClickable && "hover:bg-core-primary-10", + className, + ); + + if (isClickable) { + return ( + + {content} + + ); + } + + return
{content}
; +}; + +export default ComponentErrors; diff --git a/client/src/protoFleet/features/kpis/components/ComponentErrors/index.ts b/client/src/protoFleet/features/kpis/components/ComponentErrors/index.ts new file mode 100644 index 000000000..49b8abdce --- /dev/null +++ b/client/src/protoFleet/features/kpis/components/ComponentErrors/index.ts @@ -0,0 +1 @@ +export { default } from "./ComponentErrors"; diff --git a/client/src/protoFleet/features/kpis/components/FleetErrors/FleetErrors.stories.tsx b/client/src/protoFleet/features/kpis/components/FleetErrors/FleetErrors.stories.tsx new file mode 100644 index 000000000..70b5ba412 --- /dev/null +++ b/client/src/protoFleet/features/kpis/components/FleetErrors/FleetErrors.stories.tsx @@ -0,0 +1,89 @@ +import { BrowserRouter } from "react-router-dom"; +import type { Meta, StoryObj } from "@storybook/react"; +import FleetErrors from "./FleetErrors"; + +const meta: Meta = { + title: "Proto Fleet/Dashboard/FleetErrors", + component: FleetErrors, + parameters: { + withRouter: false, + layout: "padded", + docs: { + description: { + component: + "Displays error status for all hardware component types in the fleet (Control Boards, Fans, Hashboards, Power Supplies). Shows count of miners needing attention for each component type. Each box links to a filtered view of the miners page showing only miners with issues for that specific component. Responsive layout: 4 columns on desktop, 2 columns on tablet, 1 column on mobile.", + }, + }, + }, + tags: ["autodocs"], + argTypes: { + controlBoardErrors: { + control: "number", + description: "Number of control board errors (0 displays 'No issues', undefined shows loading state)", + }, + fanErrors: { + control: "number", + description: "Number of fan errors (0 displays 'No issues', undefined shows loading state)", + }, + hashboardErrors: { + control: "number", + description: "Number of hashboard errors (0 displays 'No issues', undefined shows loading state)", + }, + psuErrors: { + control: "number", + description: "Number of PSU errors (0 displays 'No issues', undefined shows loading state)", + }, + className: { + control: "text", + description: "Optional CSS classes for styling", + }, + }, + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + controlBoardErrors: 0, + fanErrors: 42, + hashboardErrors: 58, + psuErrors: 0, + }, +}; + +export const Loading: Story = { + args: { + controlBoardErrors: undefined, + fanErrors: undefined, + hashboardErrors: undefined, + psuErrors: undefined, + }, +}; + +export const NoErrors: Story = { + args: { + controlBoardErrors: 0, + fanErrors: 0, + hashboardErrors: 0, + psuErrors: 0, + }, +}; + +export const AllErrors: Story = { + args: { + controlBoardErrors: 12, + fanErrors: 42, + hashboardErrors: 58, + psuErrors: 7, + }, +}; diff --git a/client/src/protoFleet/features/kpis/components/FleetErrors/FleetErrors.test.tsx b/client/src/protoFleet/features/kpis/components/FleetErrors/FleetErrors.test.tsx new file mode 100644 index 000000000..041d4a422 --- /dev/null +++ b/client/src/protoFleet/features/kpis/components/FleetErrors/FleetErrors.test.tsx @@ -0,0 +1,68 @@ +import { BrowserRouter } from "react-router-dom"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import FleetErrors from "./FleetErrors"; + +describe("FleetErrors", () => { + it("renders all four hardware error sections", () => { + render( + + + , + ); + + expect(screen.getByText("Control Boards")).toBeInTheDocument(); + expect(screen.getByText("Fans")).toBeInTheDocument(); + expect(screen.getByText("Hashboards")).toBeInTheDocument(); + expect(screen.getByText("Power supplies")).toBeInTheDocument(); + }); + + it("displays correct error counts", () => { + render( + + + , + ); + + const noIssues = screen.getAllByText("No issues"); + expect(noIssues).toHaveLength(2); + expect(screen.getByText("42 miners need attention")).toBeInTheDocument(); + expect(screen.getByText("58 miners need attention")).toBeInTheDocument(); + }); + + it("renders all components as links with correct filters when errors exist", () => { + render( + + + , + ); + + const links = screen.getAllByRole("link"); + expect(links).toHaveLength(4); + expect(links[0]).toHaveAttribute("href", "/miners?issues=control-board"); + expect(links[1]).toHaveAttribute("href", "/miners?issues=fans"); + expect(links[2]).toHaveAttribute("href", "/miners?issues=hash-boards"); + expect(links[3]).toHaveAttribute("href", "/miners?issues=psu"); + }); + + it("does not render as links when error counts are zero", () => { + render( + + + , + ); + + expect(screen.queryAllByRole("link")).toHaveLength(0); + }); + + it("applies custom className", () => { + const { container } = render( + + + , + ); + + const fleetErrors = container.firstChild as HTMLElement; + expect(fleetErrors).toHaveClass("custom-class"); + }); +}); diff --git a/client/src/protoFleet/features/kpis/components/FleetErrors/FleetErrors.tsx b/client/src/protoFleet/features/kpis/components/FleetErrors/FleetErrors.tsx new file mode 100644 index 000000000..f1dc0b410 --- /dev/null +++ b/client/src/protoFleet/features/kpis/components/FleetErrors/FleetErrors.tsx @@ -0,0 +1,52 @@ +import ComponentErrors from "../ComponentErrors"; +import ControlBoard from "@/shared/assets/icons/ControlBoard"; +import Fan from "@/shared/assets/icons/Fan"; +import Hashboard from "@/shared/assets/icons/Hashboard"; +import LightningAlt from "@/shared/assets/icons/LightningAlt"; + +type FleetErrorsProps = { + controlBoardErrors?: number; + fanErrors?: number; + hashboardErrors?: number; + psuErrors?: number; + className?: string; + extraFilterParams?: string; +}; + +const FleetErrors = ({ + controlBoardErrors, + fanErrors, + hashboardErrors, + psuErrors, + className, + extraFilterParams, +}: FleetErrorsProps) => { + const suffix = extraFilterParams ? `&${extraFilterParams}` : ""; + return ( +
+
+ } + heading="Control Boards" + errorCount={controlBoardErrors} + href={`/miners?issues=control-board${suffix}`} + /> + } heading="Fans" errorCount={fanErrors} href={`/miners?issues=fans${suffix}`} /> + } + heading="Hashboards" + errorCount={hashboardErrors} + href={`/miners?issues=hash-boards${suffix}`} + /> + } + heading="Power supplies" + errorCount={psuErrors} + href={`/miners?issues=psu${suffix}`} + /> +
+
+ ); +}; + +export default FleetErrors; diff --git a/client/src/protoFleet/features/kpis/components/FleetErrors/index.ts b/client/src/protoFleet/features/kpis/components/FleetErrors/index.ts new file mode 100644 index 000000000..f381e9bbf --- /dev/null +++ b/client/src/protoFleet/features/kpis/components/FleetErrors/index.ts @@ -0,0 +1 @@ +export { default } from "./FleetErrors"; diff --git a/client/src/protoFleet/features/onboarding/components/CompleteSetup/CompleteSetup.stories.tsx b/client/src/protoFleet/features/onboarding/components/CompleteSetup/CompleteSetup.stories.tsx new file mode 100644 index 000000000..3100bb174 --- /dev/null +++ b/client/src/protoFleet/features/onboarding/components/CompleteSetup/CompleteSetup.stories.tsx @@ -0,0 +1,121 @@ +import { ReactNode } from "react"; +import { action } from "storybook/actions"; +import { Alert, MiningPools } from "@/shared/assets/icons"; +import Button from "@/shared/components/Button"; + +type TaskCardProps = { + icon: ReactNode; + title: string; + description?: string; + actionText?: string; + onActionClick?: () => void; + skippable?: boolean; + onSkip?: () => void; + isLoading?: boolean; +}; + +const TaskCard = ({ + icon, + title, + description, + actionText, + onActionClick, + skippable = false, + onSkip, + isLoading = false, +}: TaskCardProps) => { + return ( +
+
+
{icon}
+
+
{title}
+ {description &&
{description}
} +
+
+
+ {skippable && ( + + )} + +
+
+ ); +}; + +type CompleteSetupStoryProps = { + poolNeededCount: number; + authNeededCount: number; + isLoading?: boolean; +}; + +const CompleteSetupStory = ({ poolNeededCount, authNeededCount, isLoading = false }: CompleteSetupStoryProps) => { + const hasConfigurePoolCard = poolNeededCount > 0; + const hasAuthCard = authNeededCount > 0; + + if (!hasConfigurePoolCard && !hasAuthCard) { + return null; + } + + return ( +
+
+
+
Complete setup
+
}> +
+
+ {hasConfigurePoolCard && ( + } + title="Configure pools" + description={`${poolNeededCount} ${poolNeededCount === 1 ? "miner" : "miners"}`} + actionText="Configure" + onActionClick={action("configure pools")} + skippable + onSkip={action("skip configure pools")} + isLoading={isLoading} + /> + )} + {hasAuthCard && ( + } + title="Authenticate miners" + description={`${authNeededCount} miner${authNeededCount === 1 ? "" : "s"} ${authNeededCount === 1 ? "needs" : "need"} attention`} + actionText="Authenticate" + onActionClick={action("authenticate miners")} + /> + )} +
+
+
+ ); +}; + +export const BothCards = () => ; + +export const OnlyConfigurePools = () => ; + +export const OnlyAuthenticateMiners = () => ; + +export const ConfigurePoolsLoading = () => ( + +); + +export const SingleMiner = () => ; + +export const ManyMiners = () => ; + +export default { + title: "Proto Fleet/Onboarding/Complete Setup", +}; diff --git a/client/src/protoFleet/features/onboarding/components/CompleteSetup/CompleteSetup.test.tsx b/client/src/protoFleet/features/onboarding/components/CompleteSetup/CompleteSetup.test.tsx new file mode 100644 index 000000000..9da6f531c --- /dev/null +++ b/client/src/protoFleet/features/onboarding/components/CompleteSetup/CompleteSetup.test.tsx @@ -0,0 +1,922 @@ +import React from "react"; +import { MemoryRouter } from "react-router-dom"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import CompleteSetup from "./CompleteSetup"; +import useAuthNeededMiners from "@/protoFleet/api/useAuthNeededMiners"; +import { useMinerCommand } from "@/protoFleet/api/useMinerCommand"; +import usePoolNeededCount from "@/protoFleet/api/usePoolNeededCount"; + +vi.mock("@/protoFleet/api/useAuthNeededMiners"); +vi.mock("@/protoFleet/api/usePoolNeededCount"); +vi.mock("@/protoFleet/api/useMinerCommand"); +const mockRefetchMiners = vi.fn(); +vi.mock("@/shared/hooks/useReactiveLocalStorage"); +vi.mock("@/protoFleet/features/auth/components/AuthenticateFleetModal", () => ({ + default: ({ + open, + onAuthenticated, + }: { + open: boolean; + onAuthenticated: (username: string, password: string) => void; + }) => + open ? ( +
+ +
+ ) : null, +})); +vi.mock("@/protoFleet/features/auth/components/AuthenticateMiners", () => ({ + AuthenticateMiners: ({ open }: { open?: boolean }) => + open ?
Authenticate Miners Modal
: null, +})); +vi.mock("@/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage", () => ({ + default: () =>
Pool Selection Modal
, +})); + +// Mock motion to render without animations in tests +vi.mock("motion/react", () => ({ + motion: { + div: ({ children, ...props }: React.ComponentProps<"div">) =>
{children}
, + }, + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +const mockRefetchAuthNeededMiners = vi.fn(); +const mockRefetchPoolNeededCount = vi.fn(); +const mockStreamCommandBatchUpdates = vi.fn(); + +beforeEach(async () => { + vi.clearAllMocks(); + + // Default mock values + vi.mocked(useAuthNeededMiners).mockReturnValue({ + minerIds: [], + miners: {}, + totalMiners: 0, + hasMore: false, + isLoading: false, + hasInitialLoadCompleted: true, + availableModels: [], + loadMore: vi.fn(), + refetch: mockRefetchAuthNeededMiners, + }); + + vi.mocked(usePoolNeededCount).mockReturnValue({ + poolNeededCount: 0, + isLoading: false, + hasInitialLoadCompleted: true, + refetch: mockRefetchPoolNeededCount, + }); + + vi.mocked(useMinerCommand).mockReturnValue({ + streamCommandBatchUpdates: mockStreamCommandBatchUpdates, + blinkLED: vi.fn(), + startMining: vi.fn(), + stopMining: vi.fn(), + deleteMiners: vi.fn(), + reboot: vi.fn(), + updateMiningPools: vi.fn(), + setPowerTarget: vi.fn(), + setCoolingMode: vi.fn(), + updateMinerPassword: vi.fn(), + checkCommandCapabilities: vi.fn(), + downloadLogs: vi.fn(), + firmwareUpdate: vi.fn(), + getCommandBatchLogBundle: vi.fn(), + }); + + // Mock localStorage to return both values used in CompleteSetup + const { useReactiveLocalStorage } = await import("@/shared/hooks/useReactiveLocalStorage"); + vi.mocked(useReactiveLocalStorage).mockImplementation((key: string) => { + if (key === "completeSetupDismissed") { + return [false, vi.fn()]; + } + if (key === "configurePoolDismissed") { + return [false, vi.fn()]; + } + return [false, vi.fn()]; + }); +}); + +describe("CompleteSetup", () => { + const renderCompleteSetup = (props: { lastPairingCompletedAt?: number; onRefetchMiners?: () => void } = {}) => { + return render( + + + , + ); + }; + + describe("Visibility conditions", () => { + it("does not render when no miners need pools and no miners need auth", () => { + vi.mocked(usePoolNeededCount).mockReturnValue({ + poolNeededCount: 0, + isLoading: false, + hasInitialLoadCompleted: true, + refetch: mockRefetchPoolNeededCount, + }); + + vi.mocked(useAuthNeededMiners).mockReturnValue({ + minerIds: [], + miners: {}, + totalMiners: 0, + hasMore: false, + isLoading: false, + hasInitialLoadCompleted: true, + availableModels: [], + loadMore: vi.fn(), + refetch: mockRefetchAuthNeededMiners, + }); + + renderCompleteSetup(); + + expect(screen.queryByText("Complete setup")).not.toBeInTheDocument(); + }); + + it("renders when miners need pools", () => { + vi.mocked(usePoolNeededCount).mockReturnValue({ + poolNeededCount: 5, + isLoading: false, + hasInitialLoadCompleted: true, + refetch: mockRefetchPoolNeededCount, + }); + + renderCompleteSetup(); + + expect(screen.getByText("Complete setup")).toBeInTheDocument(); + expect(screen.getByText("Configure pools")).toBeInTheDocument(); + expect(screen.getByText("5 miners")).toBeInTheDocument(); + }); + + it("renders when miners need authentication", () => { + vi.mocked(useAuthNeededMiners).mockReturnValue({ + minerIds: ["miner1", "miner2"], + miners: {}, + totalMiners: 2, + hasMore: false, + isLoading: false, + hasInitialLoadCompleted: true, + availableModels: [], + loadMore: vi.fn(), + refetch: mockRefetchAuthNeededMiners, + }); + + renderCompleteSetup(); + + expect(screen.getByText("Complete setup")).toBeInTheDocument(); + expect(screen.getByText("Authenticate miners")).toBeInTheDocument(); + expect(screen.getByText("2 miners need attention")).toBeInTheDocument(); + }); + + it("renders both cards when miners need pools and auth", () => { + vi.mocked(usePoolNeededCount).mockReturnValue({ + poolNeededCount: 3, + isLoading: false, + hasInitialLoadCompleted: true, + refetch: mockRefetchPoolNeededCount, + }); + + vi.mocked(useAuthNeededMiners).mockReturnValue({ + minerIds: ["miner1"], + miners: {}, + totalMiners: 1, + hasMore: false, + isLoading: false, + hasInitialLoadCompleted: true, + availableModels: [], + loadMore: vi.fn(), + refetch: mockRefetchAuthNeededMiners, + }); + + renderCompleteSetup(); + + expect(screen.getByText("Complete setup")).toBeInTheDocument(); + expect(screen.getByText("Configure pools")).toBeInTheDocument(); + expect(screen.getByText("3 miners")).toBeInTheDocument(); + expect(screen.getByText("Authenticate miners")).toBeInTheDocument(); + expect(screen.getByText("1 miner needs attention")).toBeInTheDocument(); + }); + + it("does not render when complete setup is dismissed", async () => { + const { useReactiveLocalStorage } = await import("@/shared/hooks/useReactiveLocalStorage"); + vi.mocked(useReactiveLocalStorage).mockImplementation((key: string) => { + if (key === "completeSetupDismissed") { + return [true, vi.fn()]; + } + return [false, vi.fn()]; + }); + + vi.mocked(usePoolNeededCount).mockReturnValue({ + poolNeededCount: 3, + isLoading: false, + hasInitialLoadCompleted: true, + refetch: mockRefetchPoolNeededCount, + }); + + renderCompleteSetup(); + + expect(screen.queryByText("Complete setup")).not.toBeInTheDocument(); + }); + + it("does not render configure pools card when dismissed separately", async () => { + const { useReactiveLocalStorage } = await import("@/shared/hooks/useReactiveLocalStorage"); + vi.mocked(useReactiveLocalStorage).mockImplementation((key: string) => { + if (key === "configurePoolDismissed") { + return [true, vi.fn()]; + } + return [false, vi.fn()]; + }); + + vi.mocked(usePoolNeededCount).mockReturnValue({ + poolNeededCount: 3, + isLoading: false, + hasInitialLoadCompleted: true, + refetch: mockRefetchPoolNeededCount, + }); + + renderCompleteSetup(); + + expect(screen.queryByText("Configure pools")).not.toBeInTheDocument(); + }); + }); + + describe("Dismiss functionality", () => { + it("dismisses complete setup when dismiss button clicked", async () => { + const setCompleteSetupDismissed = vi.fn(); + const { useReactiveLocalStorage } = await import("@/shared/hooks/useReactiveLocalStorage"); + vi.mocked(useReactiveLocalStorage).mockImplementation((key: string) => { + if (key === "completeSetupDismissed") { + return [false, setCompleteSetupDismissed]; + } + return [false, vi.fn()]; + }); + + vi.mocked(usePoolNeededCount).mockReturnValue({ + poolNeededCount: 3, + isLoading: false, + hasInitialLoadCompleted: true, + refetch: mockRefetchPoolNeededCount, + }); + + renderCompleteSetup(); + + const dismissButton = screen.getByRole("button", { name: "Dismiss complete setup" }); + fireEvent.click(dismissButton); + + expect(setCompleteSetupDismissed).toHaveBeenCalledWith(true); + }); + }); + + describe("ConfigurePoolCard", () => { + it("renders configure pools card with correct count", () => { + vi.mocked(usePoolNeededCount).mockReturnValue({ + poolNeededCount: 5, + isLoading: false, + hasInitialLoadCompleted: true, + refetch: mockRefetchPoolNeededCount, + }); + + renderCompleteSetup(); + + expect(screen.getByText("Configure pools")).toBeInTheDocument(); + expect(screen.getByText("5 miners")).toBeInTheDocument(); + expect(screen.getByText("Configure")).toBeInTheDocument(); + expect(screen.getByText("Skip")).toBeInTheDocument(); + }); + + it("uses singular form for one miner", () => { + vi.mocked(usePoolNeededCount).mockReturnValue({ + poolNeededCount: 1, + isLoading: false, + hasInitialLoadCompleted: true, + refetch: mockRefetchPoolNeededCount, + }); + + renderCompleteSetup(); + + expect(screen.getByText("1 miner")).toBeInTheDocument(); + }); + + it("does not render configure pools card when no miners need pools", () => { + vi.mocked(usePoolNeededCount).mockReturnValue({ + poolNeededCount: 0, + isLoading: false, + hasInitialLoadCompleted: true, + refetch: mockRefetchPoolNeededCount, + }); + + vi.mocked(useAuthNeededMiners).mockReturnValue({ + minerIds: ["miner1"], + miners: {}, + totalMiners: 1, + hasMore: false, + isLoading: false, + hasInitialLoadCompleted: true, + availableModels: [], + loadMore: vi.fn(), + refetch: mockRefetchAuthNeededMiners, + }); + + renderCompleteSetup(); + + expect(screen.queryByText("Configure pools")).not.toBeInTheDocument(); + }); + + it("opens pool selection modal when configure button clicked", async () => { + vi.mocked(usePoolNeededCount).mockReturnValue({ + poolNeededCount: 5, + isLoading: false, + hasInitialLoadCompleted: true, + refetch: mockRefetchPoolNeededCount, + }); + + renderCompleteSetup(); + + const configureButton = screen.getByText("Configure"); + fireEvent.click(configureButton); + + await waitFor(() => { + expect(screen.getByTestId("auth-fleet-modal")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText("Submit Auth")); + + await waitFor(() => { + expect(screen.getByTestId("pool-selection-modal")).toBeInTheDocument(); + }); + }); + + it("dismisses configure pools card when skip button clicked", async () => { + const setConfigurePoolDismissed = vi.fn(); + const { useReactiveLocalStorage } = await import("@/shared/hooks/useReactiveLocalStorage"); + vi.mocked(useReactiveLocalStorage).mockImplementation((key: string) => { + if (key === "configurePoolDismissed") { + return [false, setConfigurePoolDismissed]; + } + return [false, vi.fn()]; + }); + + vi.mocked(usePoolNeededCount).mockReturnValue({ + poolNeededCount: 5, + isLoading: false, + hasInitialLoadCompleted: true, + refetch: mockRefetchPoolNeededCount, + }); + + renderCompleteSetup(); + + const skipButton = screen.getByText("Skip"); + fireEvent.click(skipButton); + + expect(setConfigurePoolDismissed).toHaveBeenCalledWith(true); + }); + + it("shows loading state when fetching miners", () => { + vi.mocked(usePoolNeededCount).mockReturnValue({ + poolNeededCount: 5, + isLoading: true, + hasInitialLoadCompleted: false, + refetch: mockRefetchPoolNeededCount, + }); + + renderCompleteSetup(); + + // Check that the button is in loading state + const configureButton = screen.getByRole("button", { name: /configure/i }); + expect(configureButton).toHaveAttribute("disabled"); + }); + + it("removes entire component when configure pools card is skipped and it's the only card", async () => { + const setConfigurePoolDismissed = vi.fn(); + const { useReactiveLocalStorage } = await import("@/shared/hooks/useReactiveLocalStorage"); + + // Start with configurePoolDismissed = false + vi.mocked(useReactiveLocalStorage).mockImplementation((key: string) => { + if (key === "configurePoolDismissed") { + return [false, setConfigurePoolDismissed]; + } + return [false, vi.fn()]; + }); + + vi.mocked(usePoolNeededCount).mockReturnValue({ + poolNeededCount: 5, + isLoading: false, + hasInitialLoadCompleted: true, + refetch: mockRefetchPoolNeededCount, + }); + + // No auth card showing + vi.mocked(useAuthNeededMiners).mockReturnValue({ + minerIds: [], + miners: {}, + totalMiners: 0, + hasMore: false, + isLoading: false, + hasInitialLoadCompleted: true, + availableModels: [], + loadMore: vi.fn(), + refetch: mockRefetchAuthNeededMiners, + }); + + const { rerender } = renderCompleteSetup(); + + expect(screen.getByText("Complete setup")).toBeInTheDocument(); + expect(screen.getByText("Configure pools")).toBeInTheDocument(); + + // Click skip button + const skipButton = screen.getByText("Skip"); + fireEvent.click(skipButton); + + expect(setConfigurePoolDismissed).toHaveBeenCalledWith(true); + + // Simulate the card being dismissed by updating the mock + vi.mocked(useReactiveLocalStorage).mockImplementation((key: string) => { + if (key === "configurePoolDismissed") { + return [true, setConfigurePoolDismissed]; + } + return [false, vi.fn()]; + }); + + // Rerender to reflect the dismissed state + rerender( + + + , + ); + + // Entire component should be removed since no cards are showing + expect(screen.queryByText("Complete setup")).not.toBeInTheDocument(); + }); + + it("keeps component visible when configure pools card is skipped but auth card is still showing", async () => { + const setConfigurePoolDismissed = vi.fn(); + const { useReactiveLocalStorage } = await import("@/shared/hooks/useReactiveLocalStorage"); + + vi.mocked(useReactiveLocalStorage).mockImplementation((key: string) => { + if (key === "configurePoolDismissed") { + return [false, setConfigurePoolDismissed]; + } + return [false, vi.fn()]; + }); + + vi.mocked(usePoolNeededCount).mockReturnValue({ + poolNeededCount: 5, + isLoading: false, + hasInitialLoadCompleted: true, + refetch: mockRefetchPoolNeededCount, + }); + + // Auth card is showing + vi.mocked(useAuthNeededMiners).mockReturnValue({ + minerIds: ["miner1"], + miners: {}, + totalMiners: 1, + hasMore: false, + isLoading: false, + hasInitialLoadCompleted: true, + availableModels: [], + loadMore: vi.fn(), + refetch: mockRefetchAuthNeededMiners, + }); + + const { rerender } = renderCompleteSetup(); + + expect(screen.getByText("Complete setup")).toBeInTheDocument(); + expect(screen.getByText("Configure pools")).toBeInTheDocument(); + expect(screen.getByText("Authenticate miners")).toBeInTheDocument(); + + // Click skip button on configure pools card + const skipButton = screen.getByText("Skip"); + fireEvent.click(skipButton); + + expect(setConfigurePoolDismissed).toHaveBeenCalledWith(true); + + // Simulate the card being dismissed + vi.mocked(useReactiveLocalStorage).mockImplementation((key: string) => { + if (key === "configurePoolDismissed") { + return [true, setConfigurePoolDismissed]; + } + return [false, vi.fn()]; + }); + + rerender( + + + , + ); + + // Component should still be visible because auth card is showing + expect(screen.getByText("Complete setup")).toBeInTheDocument(); + expect(screen.queryByText("Configure pools")).not.toBeInTheDocument(); + expect(screen.getByText("Authenticate miners")).toBeInTheDocument(); + }); + }); + + describe("AuthenticateMinersCard", () => { + it("renders authenticate miners card when miners need auth", () => { + vi.mocked(useAuthNeededMiners).mockReturnValue({ + minerIds: ["miner1", "miner2", "miner3"], + miners: {}, + totalMiners: 3, + hasMore: false, + isLoading: false, + hasInitialLoadCompleted: true, + availableModels: [], + loadMore: vi.fn(), + refetch: mockRefetchAuthNeededMiners, + }); + + renderCompleteSetup(); + + expect(screen.getByText("Authenticate miners")).toBeInTheDocument(); + expect(screen.getByText("3 miners need attention")).toBeInTheDocument(); + }); + + it("uses singular form for one miner", () => { + vi.mocked(useAuthNeededMiners).mockReturnValue({ + minerIds: ["miner1"], + miners: {}, + totalMiners: 1, + hasMore: false, + isLoading: false, + hasInitialLoadCompleted: true, + availableModels: [], + loadMore: vi.fn(), + refetch: mockRefetchAuthNeededMiners, + }); + + renderCompleteSetup(); + + expect(screen.getByText("1 miner needs attention")).toBeInTheDocument(); + }); + + it("does not render authenticate miners card when no miners need auth", () => { + vi.mocked(usePoolNeededCount).mockReturnValue({ + poolNeededCount: 3, + isLoading: false, + hasInitialLoadCompleted: true, + refetch: mockRefetchPoolNeededCount, + }); + + vi.mocked(useAuthNeededMiners).mockReturnValue({ + minerIds: [], + miners: {}, + totalMiners: 0, + hasMore: false, + isLoading: false, + hasInitialLoadCompleted: true, + availableModels: [], + loadMore: vi.fn(), + refetch: mockRefetchAuthNeededMiners, + }); + + renderCompleteSetup(); + + expect(screen.queryByText("Authenticate miners")).not.toBeInTheDocument(); + }); + }); + + describe("Polling after pairing completion", () => { + it("starts polling when pairing completes", async () => { + vi.mocked(useAuthNeededMiners).mockReturnValue({ + minerIds: ["miner1"], + miners: {}, + totalMiners: 1, + hasMore: false, + isLoading: false, + hasInitialLoadCompleted: true, + availableModels: [], + loadMore: vi.fn(), + refetch: mockRefetchAuthNeededMiners, + }); + + vi.mocked(usePoolNeededCount).mockReturnValue({ + poolNeededCount: 0, + isLoading: false, + hasInitialLoadCompleted: true, + refetch: mockRefetchPoolNeededCount, + }); + + const { rerender } = renderCompleteSetup(); + + // Reset call counts before simulating pairing + mockRefetchAuthNeededMiners.mockClear(); + mockRefetchPoolNeededCount.mockClear(); + + // Simulate pairing completion by updating the timestamp prop + const timestamp = Date.now(); + + rerender( + + + , + ); + + // First poll should happen after 1s initial delay + await waitFor( + () => { + expect(mockRefetchAuthNeededMiners).toHaveBeenCalled(); + expect(mockRefetchPoolNeededCount).toHaveBeenCalled(); + }, + { timeout: 1500 }, + ); + }); + + it("stops polling when poolNeededCount changes from initial value", async () => { + vi.mocked(useAuthNeededMiners).mockReturnValue({ + minerIds: [], + miners: {}, + totalMiners: 0, + hasMore: false, + isLoading: false, + hasInitialLoadCompleted: true, + availableModels: [], + loadMore: vi.fn(), + refetch: mockRefetchAuthNeededMiners, + }); + + // Start with 0 miners + const mockPoolNeededHook = vi.mocked(usePoolNeededCount); + mockPoolNeededHook.mockReturnValue({ + poolNeededCount: 0, + isLoading: false, + hasInitialLoadCompleted: true, + refetch: mockRefetchPoolNeededCount, + }); + + const timestamp = Date.now(); + const { rerender } = renderCompleteSetup({ lastPairingCompletedAt: undefined }); + + // Simulate pairing completion + rerender( + + + , + ); + + // Wait for first poll + await waitFor( + () => { + expect(mockRefetchPoolNeededCount).toHaveBeenCalled(); + }, + { timeout: 1500 }, + ); + + // Simulate backend detecting miners with NEEDS_MINING_POOL status + mockPoolNeededHook.mockReturnValue({ + poolNeededCount: 5, + isLoading: false, + hasInitialLoadCompleted: true, + refetch: mockRefetchPoolNeededCount, + }); + + // Rerender with new pool count + rerender( + + + , + ); + + // Polling should have stopped, so no additional calls after a delay + const callCount = mockRefetchPoolNeededCount.mock.calls.length; + await new Promise((resolve) => setTimeout(resolve, 600)); + expect(mockRefetchPoolNeededCount).toHaveBeenCalledTimes(callCount); + }); + + it("does not refetch when pairing timestamp is 0", () => { + vi.mocked(useAuthNeededMiners).mockReturnValue({ + minerIds: ["miner1"], + miners: {}, + totalMiners: 1, + hasMore: false, + isLoading: false, + hasInitialLoadCompleted: true, + availableModels: [], + loadMore: vi.fn(), + refetch: mockRefetchAuthNeededMiners, + }); + + renderCompleteSetup(); + + expect(mockRefetchAuthNeededMiners).not.toHaveBeenCalled(); + expect(mockRefetchPoolNeededCount).not.toHaveBeenCalled(); + }); + + it("does not start new polling if timestamp is same as previous", async () => { + vi.mocked(useAuthNeededMiners).mockReturnValue({ + minerIds: ["miner1"], + miners: {}, + totalMiners: 1, + hasMore: false, + isLoading: false, + hasInitialLoadCompleted: true, + availableModels: [], + loadMore: vi.fn(), + refetch: mockRefetchAuthNeededMiners, + }); + + const timestamp = Date.now(); + + const { rerender } = renderCompleteSetup({ lastPairingCompletedAt: timestamp }); + + await waitFor( + () => { + expect(mockRefetchAuthNeededMiners).toHaveBeenCalled(); + }, + { timeout: 1500 }, + ); + + const callCountAfterFirst = mockRefetchAuthNeededMiners.mock.calls.length; + + // Rerender with same timestamp should not trigger new polling + rerender( + + + , + ); + + // Wait a bit and verify no new calls were made + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(mockRefetchAuthNeededMiners).toHaveBeenCalledTimes(callCountAfterFirst); + }); + }); + + describe("Pool assignment flow", () => { + it("passes correct miners to pool selection modal", async () => { + vi.mocked(usePoolNeededCount).mockReturnValue({ + poolNeededCount: 3, + isLoading: false, + hasInitialLoadCompleted: true, + refetch: mockRefetchPoolNeededCount, + }); + + renderCompleteSetup(); + + const configureButton = screen.getByText("Configure"); + fireEvent.click(configureButton); + + await waitFor(() => { + expect(screen.getByTestId("auth-fleet-modal")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText("Submit Auth")); + + await waitFor(() => { + expect(screen.getByTestId("pool-selection-modal")).toBeInTheDocument(); + }); + }); + + it("shows loading state on configure pools card during polling after pool assignment", async () => { + vi.mocked(usePoolNeededCount).mockReturnValue({ + poolNeededCount: 2, + isLoading: false, + hasInitialLoadCompleted: true, + refetch: mockRefetchPoolNeededCount, + }); + + const { rerender } = renderCompleteSetup(); + + // Verify initial state - button is not loading + let configureButton = screen.getByText("Configure"); + expect(configureButton).not.toHaveAttribute("disabled"); + + // Trigger pool assignment success by simulating the pairing timestamp update + // which triggers polling + const timestamp = Date.now(); + + rerender( + + + , + ); + + // Wait for polling to start + await waitFor( + () => { + expect(mockRefetchPoolNeededCount).toHaveBeenCalled(); + }, + { timeout: 1500 }, + ); + + // Button should now be in loading state + configureButton = screen.getByRole("button", { name: /configure/i }); + expect(configureButton).toHaveAttribute("disabled"); + }); + + it("stops polling and exits loading state when pool count changes to 0", async () => { + const mockPoolNeededHook = vi.mocked(usePoolNeededCount); + + // Start with miners needing pools + mockPoolNeededHook.mockReturnValue({ + poolNeededCount: 5, + isLoading: false, + hasInitialLoadCompleted: true, + refetch: mockRefetchPoolNeededCount, + }); + + const { rerender } = renderCompleteSetup(); + + // Trigger polling + const timestamp = Date.now(); + + rerender( + + + , + ); + + // Wait for polling to start + await waitFor( + () => { + expect(mockRefetchPoolNeededCount).toHaveBeenCalled(); + }, + { timeout: 1500 }, + ); + + // Simulate pool configuration completing - count goes to 0 + mockPoolNeededHook.mockReturnValue({ + poolNeededCount: 0, + isLoading: false, + hasInitialLoadCompleted: true, + refetch: mockRefetchPoolNeededCount, + }); + + rerender( + + + , + ); + + // Component should be removed since no cards are showing + await waitFor(() => { + expect(screen.queryByText("Complete setup")).not.toBeInTheDocument(); + }); + }); + + it("exits loading state after all polls complete even when pool count unchanged", async () => { + vi.useFakeTimers(); + + try { + const mockPoolNeededHook = vi.mocked(usePoolNeededCount); + + // Start with miners needing pools + mockPoolNeededHook.mockReturnValue({ + poolNeededCount: 5, + isLoading: false, + hasInitialLoadCompleted: true, + refetch: mockRefetchPoolNeededCount, + }); + + const { rerender } = renderCompleteSetup(); + + // Trigger polling via pairing completion + const timestamp = Date.now(); + + rerender( + + + , + ); + + // Button should be in loading state after polling starts + let configureButton = screen.getByRole("button", { name: /configure/i }); + expect(configureButton).toHaveAttribute("disabled"); + + // Advance through all 10 polls and flush React state updates + // Total polling time: 1000ms initial delay + 9 × 2000ms intervals = 19000ms + await act(async () => { + await vi.advanceTimersByTimeAsync(19000); + }); + + // After all polls complete, button should no longer be disabled + configureButton = screen.getByRole("button", { name: /configure/i }); + expect(configureButton).not.toHaveAttribute("disabled"); + } finally { + vi.useRealTimers(); + } + }); + }); + + it("applies custom className when provided", () => { + vi.mocked(usePoolNeededCount).mockReturnValue({ + poolNeededCount: 3, + isLoading: false, + hasInitialLoadCompleted: true, + refetch: mockRefetchPoolNeededCount, + }); + + const { container } = render( + + + , + ); + + const outerDiv = container.firstChild as HTMLElement; + expect(outerDiv).toHaveClass("custom-class"); + }); +}); diff --git a/client/src/protoFleet/features/onboarding/components/CompleteSetup/CompleteSetup.tsx b/client/src/protoFleet/features/onboarding/components/CompleteSetup/CompleteSetup.tsx new file mode 100644 index 000000000..2129647c7 --- /dev/null +++ b/client/src/protoFleet/features/onboarding/components/CompleteSetup/CompleteSetup.tsx @@ -0,0 +1,485 @@ +import { AnimatePresence, motion } from "motion/react"; +import { ReactNode, useCallback, useEffect, useRef, useState } from "react"; +import { create } from "@bufbuild/protobuf"; +import { DeviceStatus, PairingStatus } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { StreamCommandBatchUpdatesRequestSchema } from "@/protoFleet/api/generated/minercommand/v1/command_pb"; +import useAuthNeededMiners from "@/protoFleet/api/useAuthNeededMiners"; +import { useMinerCommand } from "@/protoFleet/api/useMinerCommand"; +import usePoolNeededCount from "@/protoFleet/api/usePoolNeededCount"; +import AuthenticateFleetModal from "@/protoFleet/features/auth/components/AuthenticateFleetModal"; +import { AuthenticateMiners } from "@/protoFleet/features/auth/components/AuthenticateMiners"; +import PoolSelectionPageWrapper from "@/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage"; +import { Alert, Dismiss, MiningPools } from "@/shared/assets/icons"; +import Button from "@/shared/components/Button"; +import { pushToast, STATUSES as TOAST_STATUSES } from "@/shared/features/toaster"; +import { useReactiveLocalStorage } from "@/shared/hooks/useReactiveLocalStorage"; + +type TaskCardProps = { + icon: ReactNode; + title: string; + description?: string; + actionText?: string; + onActionClick?: () => void; + skippable?: boolean; + onSkip?: () => void; + isLoading?: boolean; +}; + +const TaskCard = ({ + icon, + title, + description, + actionText, + onActionClick, + skippable = false, + onSkip, + isLoading = false, +}: TaskCardProps) => { + return ( +
+
+
{icon}
+
+
{title}
+ {description &&
{description}
} +
+
+
+ {skippable && ( + + )} + +
+
+ ); +}; + +const AuthenticateMinersCard = ({ + count, + onAuthenticationSuccess, + onRefetchMiners, + onPairingCompleted, +}: { + count: number; + onAuthenticationSuccess: () => void; + onRefetchMiners?: () => void; + onPairingCompleted?: () => void; +}) => { + const [showAuthMinersModal, setShowAuthMinersModal] = useState(false); + + return ( + <> + } + title="Authenticate miners" + description={`${count} miner${count === 1 ? "" : "s"} ${count === 1 ? "needs" : "need"} attention`} + actionText="Authenticate" + onActionClick={() => setShowAuthMinersModal(true)} + /> + setShowAuthMinersModal(false)} + onSuccess={onAuthenticationSuccess} + onRefetchMiners={onRefetchMiners} + onPairingCompleted={onPairingCompleted} + /> + + ); +}; + +const ConfigurePoolCard = ({ + count, + onConfigureClick, + isLoading, +}: { + count: number; + onConfigureClick: () => void; + isLoading: boolean; +}) => { + const [configurePoolDismissed, setConfigurePoolDismissed] = + useReactiveLocalStorage("configurePoolDismissed"); + + if (configurePoolDismissed) { + return null; + } + + return ( + } + title="Configure pools" + description={`${count} ${count === 1 ? "miner" : "miners"}`} + actionText="Configure" + onActionClick={onConfigureClick} + skippable + onSkip={() => setConfigurePoolDismissed(true)} + isLoading={isLoading} + /> + ); +}; + +type CompleteSetupProps = { + className?: string; + lastPairingCompletedAt?: number; + onRefetchMiners?: () => void; + onPairingCompleted?: () => void; +}; + +const CompleteSetup = ({ + className = "", + lastPairingCompletedAt: externalPairingTimestamp = 0, + onRefetchMiners, + onPairingCompleted: externalOnPairingCompleted, +}: CompleteSetupProps) => { + // Internal pairing state for callers that don't wire external callbacks (e.g., Dashboard). + // Uses whichever timestamp is newer: external prop or internal state. + const [internalPairingTimestamp, setInternalPairingTimestamp] = useState(0); + const lastPairingCompletedAt = Math.max(externalPairingTimestamp, internalPairingTimestamp); + const onPairingCompleted = useCallback(() => { + externalOnPairingCompleted?.(); + setInternalPairingTimestamp(Date.now()); + }, [externalOnPairingCompleted]); + const [completSetupDismissed, setCompletSetupDismissed] = useReactiveLocalStorage("completeSetupDismissed"); + + const handleDismiss = () => { + setCompletSetupDismissed(true); + }; + + // Fetch miners needing authentication to show in the "Authenticate miners" card + const { totalMiners: authNeededCount, refetch: refetchAuthNeededMiners } = useAuthNeededMiners({ + pageSize: 100, + }); + + // Fetch count of miners needing pool configuration + const { poolNeededCount, isLoading: isLoadingPoolNeeded, refetch: refetchPoolNeededCount } = usePoolNeededCount(); + + // Get streaming command batch updates + const { streamCommandBatchUpdates } = useMinerCommand(); + + // State for fleet authentication before pool assignment + const [showAuthModal, setShowAuthModal] = useState(false); + const [poolFleetCredentials, setPoolFleetCredentials] = useState<{ username: string; password: string } | undefined>( + undefined, + ); + + // State for showing pool selection modal + const [showPoolSelectionModal, setShowPoolSelectionModal] = useState(false); + + // State for tracking when we're polling after pool assignment + const [isPollingAfterPoolAssignment, setIsPollingAfterPoolAssignment] = useState(false); + + // Store cleanup function to stop polling when status is detected + const pollingCleanupRef = useRef<(() => void) | null>(null); + // Track pool count when polling starts to detect changes + const poolCountWhenPollingStartedRef = useRef(null); + // Store target count for pool assignment operation (used for toast message when complete) + const pendingPoolAssignmentRef = useRef<{ targetCount: number; failureCount: number } | null>(null); + const refetchMiners = onRefetchMiners; + + // Track latest poolNeededCount to avoid stale closure in callbacks + const poolNeededCountRef = useRef(poolNeededCount); + useEffect(() => { + poolNeededCountRef.current = poolNeededCount; + }, [poolNeededCount]); + + // Show completion toast and refresh miner table when pool assignment finishes + const finalizePoolAssignment = useCallback(() => { + if (!pendingPoolAssignmentRef.current) return; + + const { targetCount, failureCount } = pendingPoolAssignmentRef.current; + const minerLabel = targetCount === 1 ? "miner" : "miners"; + if (failureCount > 0) { + pushToast({ + message: `Pool assignment failed for ${failureCount} of ${targetCount} ${minerLabel}`, + status: TOAST_STATUSES.error, + }); + } else { + pushToast({ + message: `Assigned pools to ${targetCount} ${minerLabel}`, + status: TOAST_STATUSES.success, + }); + } + pendingPoolAssignmentRef.current = null; + + // Refresh the miner table to reflect updated statuses + refetchMiners?.(); + }, [refetchMiners]); + + // Polls for status updates with fixed 2s intervals after 1s initial delay. + // Returns cleanup function to cancel pending polls. + const pollForStatusUpdates = useCallback(() => { + setIsPollingAfterPoolAssignment(true); + poolCountWhenPollingStartedRef.current = poolNeededCountRef.current; + + let pollCount = 0; + const maxPolls = 10; + const pollIntervalMs = 2000; + const initialDelayMs = 1000; + const timeouts: ReturnType[] = []; + let cancelled = false; + + const resetPollingState = () => { + pollingCleanupRef.current = null; + poolCountWhenPollingStartedRef.current = null; + setIsPollingAfterPoolAssignment(false); + }; + + const poll = () => { + if (cancelled) return; + + refetchAuthNeededMiners(); + refetchPoolNeededCount(); + pollCount += 1; + + if (pollCount < maxPolls) { + timeouts.push(setTimeout(poll, pollIntervalMs)); + } else { + // Max polls reached - finalize and reset + finalizePoolAssignment(); + resetPollingState(); + } + }; + + // Initial delay gives backend time to process updates + timeouts.push(setTimeout(poll, initialDelayMs)); + + const cleanup = () => { + cancelled = true; + timeouts.forEach(clearTimeout); + resetPollingState(); + }; + + pollingCleanupRef.current = cleanup; + return cleanup; + }, [refetchAuthNeededMiners, refetchPoolNeededCount, finalizePoolAssignment]); + + // Stop polling and show toast when pool count decreases (pool assignment succeeded) + useEffect(() => { + // Check pending assignment first - it has the target count from when operation started + if (pendingPoolAssignmentRef.current && poolNeededCount < pendingPoolAssignmentRef.current.targetCount) { + finalizePoolAssignment(); + pollingCleanupRef.current?.(); + } + // Only check for pairing completion flow when there's no pending pool assignment. + // Without this guard, a pool count increase (e.g., another miner entering NEEDS_MINING_POOL) + // would stop polling without showing the completion toast. + else if ( + !pendingPoolAssignmentRef.current && + pollingCleanupRef.current && + poolCountWhenPollingStartedRef.current !== null + ) { + const hasChanged = poolCountWhenPollingStartedRef.current !== poolNeededCount; + if (hasChanged) { + pollingCleanupRef.current(); + } + } + }, [poolNeededCount, finalizePoolAssignment]); + + // Ensure polling is cleaned up if the component unmounts while polling is active + useEffect(() => { + return () => { + pollingCleanupRef.current?.(); + }; + }, []); + + // Handlers for pool selection modal + const handlePoolAssignmentSuccess = useCallback( + async (batchIdentifier: string) => { + setShowPoolSelectionModal(false); + + // Show loading state immediately while stream runs + setIsPollingAfterPoolAssignment(true); + + // Capture target count at operation start (miners needing pools) + const targetCount = poolNeededCountRef.current; + let failureCount = 0; + let streamErrorOccurred = false; + + const streamAbortController = new AbortController(); + + await streamCommandBatchUpdates({ + streamRequest: create(StreamCommandBatchUpdatesRequestSchema, { + batchIdentifier, + }), + onStreamData: (response) => { + const success = Number(response.status?.commandBatchDeviceCount?.success || 0); + const failure = Number(response.status?.commandBatchDeviceCount?.failure || 0); + const completed = success + failure; + const serverTotal = Number(response.status?.commandBatchDeviceCount?.total || 0); + + // Track failures for completion toast + failureCount = failure; + + // Abort stream when all devices in the batch have completed (per server-reported total) + if (serverTotal > 0 && completed >= serverTotal) { + streamAbortController.abort(); + } + }, + onError: (error) => { + streamErrorOccurred = true; + setIsPollingAfterPoolAssignment(false); + pushToast({ + message: `Pool assignment failed: ${error}`, + status: TOAST_STATUSES.error, + }); + }, + streamAbortController, + }); + + // Don't proceed with polling if stream encountered an error + if (streamErrorOccurred) { + return; + } + + // Store info for completion toast when polling detects count change + pendingPoolAssignmentRef.current = { targetCount, failureCount }; + + pollForStatusUpdates(); + }, + [streamCommandBatchUpdates, pollForStatusUpdates], + ); + + const handlePoolAssignmentError = useCallback((error: string) => { + pushToast({ + message: error, + status: TOAST_STATUSES.error, + longRunning: true, + }); + setShowPoolSelectionModal(false); + setPoolFleetCredentials(undefined); + }, []); + + const handlePoolDismiss = useCallback(() => { + setShowPoolSelectionModal(false); + setPoolFleetCredentials(undefined); + }, []); + + const handleAuthSuccess = useCallback((username: string, password: string) => { + setPoolFleetCredentials({ username, password }); + setShowAuthModal(false); + setShowPoolSelectionModal(true); + }, []); + + const handleAuthDismiss = useCallback(() => { + setShowAuthModal(false); + }, []); + + // Watch for pairing operations completing and start polling + const lastProcessedPairingTimestampRef = useRef(0); + + useEffect(() => { + if (lastPairingCompletedAt > 0 && lastPairingCompletedAt !== lastProcessedPairingTimestampRef.current) { + lastProcessedPairingTimestampRef.current = lastPairingCompletedAt; + return pollForStatusUpdates(); + } + // Note: Intentionally not including pollForStatusUpdates in deps to avoid re-running + // when refetch functions change. We only want to poll on new pairing completion. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lastPairingCompletedAt]); + + // Track which cards are dismissed to determine if we should show the component + const [configurePoolDismissed] = useReactiveLocalStorage("configurePoolDismissed"); + + // Determine which cards are visible (have content and not dismissed) + const hasConfigurePoolCard = poolNeededCount > 0 && !configurePoolDismissed; + const hasAuthCard = authNeededCount > 0; + + // Show complete setup banner if: + // 1. User hasn't explicitly dismissed the entire component AND + // 2. At least one card is visible + const shouldShow = !completSetupDismissed && (hasConfigurePoolCard || hasAuthCard); + + return ( + <> + {shouldShow && ( +
+
+
+
Complete setup
+
+
+ + {hasConfigurePoolCard && ( + + { + if (poolNeededCount === 0) { + return; + } + + setShowAuthModal(true); + }} + isLoading={isLoadingPoolNeeded || isPollingAfterPoolAssignment} + /> + + )} + {hasAuthCard && ( + + + + )} + +
+
+
+ )} + + + + ); +}; + +export default CompleteSetup; diff --git a/client/src/protoFleet/features/onboarding/components/CompleteSetup/index.ts b/client/src/protoFleet/features/onboarding/components/CompleteSetup/index.ts new file mode 100644 index 000000000..5625291c0 --- /dev/null +++ b/client/src/protoFleet/features/onboarding/components/CompleteSetup/index.ts @@ -0,0 +1,3 @@ +import CompleteSetup from "@/protoFleet/features/onboarding/components/CompleteSetup/CompleteSetup"; + +export { CompleteSetup }; diff --git a/client/src/protoFleet/features/onboarding/components/Miners/FoundMiners.tsx b/client/src/protoFleet/features/onboarding/components/Miners/FoundMiners.tsx new file mode 100644 index 000000000..22618ef95 --- /dev/null +++ b/client/src/protoFleet/features/onboarding/components/Miners/FoundMiners.tsx @@ -0,0 +1,187 @@ +import { Fragment, useEffect, useMemo, useRef, useState } from "react"; +import clsx from "clsx"; +import type { MinerWithModel } from "./types"; +import { AuthenticationMethod } from "@/protoFleet/api/generated/capabilities/v1/capabilities_pb"; +import { type Device } from "@/protoFleet/api/generated/pairing/v1/pairing_pb"; +import { Fleet, LogoAlt } from "@/shared/assets/icons"; +import Divider from "@/shared/components/Divider"; +import Header from "@/shared/components/Header"; +import Row from "@/shared/components/Row"; + +type FoundMinersProps = { + miners: Device[]; + deselectedMiners: Device["deviceIdentifier"][]; + /** Whether a network scan is actively in progress (controls title text). */ + isScanning?: boolean; + /** Whether to show skeleton loading rows (may outlast isScanning due to min display time). */ + showSkeleton?: boolean; + className?: string; +}; + +type MinersByModel = { + [key: string]: MinersByModelItem; +}; + +class MinerKey { + manufacturer: string; + model: string; + + constructor(manufacturer: string, model: string) { + this.manufacturer = manufacturer; + this.model = model; + } + + toString(): string { + return `${this.manufacturer}:${this.model}`; + } +} + +type MinersByModelItem = { + model: string; + manufacturer: string; + supportedAuthenticationMethods: AuthenticationMethod[]; + miners: MinerWithModel[]; +}; + +function isProtoRig(manufacturer: string): boolean { + return manufacturer === "Proto"; +} + +function supportsAutoAuth(supportedMethods: AuthenticationMethod[]): boolean { + return supportedMethods.includes(AuthenticationMethod.ASYMMETRIC_KEY); +} + +const SKELETON_INDICES = [0, 1, 2]; + +const SkeletonMinerRows = () => ( + <> + {SKELETON_INDICES.map((index) => ( +
+
+
+
+
+
+
+
+
+
+ ))} + +); + +const CollapsibleSkeleton = ({ visible, showDivider }: { visible: boolean; showDivider: boolean }) => { + const contentRef = useRef(null); + const [height, setHeight] = useState(undefined); + + useEffect(() => { + if (visible && contentRef.current) { + setHeight(contentRef.current.scrollHeight); + } + }, [visible, showDivider]); + + return ( +
+
+ + {showDivider && } +
+
+ ); +}; + +const FoundMiners = ({ miners, deselectedMiners, isScanning, showSkeleton, className }: FoundMinersProps) => { + const skeletonVisible = showSkeleton ?? !!isScanning; + // Derive minersByModel directly from miners prop + const minersByModel = useMemo(() => { + const _minersByModel: MinersByModel = {}; + + miners.forEach((miner) => { + const minerKey = new MinerKey(miner.manufacturer || "unknown", miner.model || "unknown"); + + if (!_minersByModel[minerKey.toString()]) { + const supportedMethods = miner.capabilities?.authentication?.supportedMethods || []; + + _minersByModel[minerKey.toString()] = { + model: miner.model, + manufacturer: miner.manufacturer || "unknown", + supportedAuthenticationMethods: supportedMethods, + miners: [miner], + }; + } else if ( + // if miner is already in our state dont add it again + // so that we dont have duplicates + !_minersByModel[minerKey.toString()].miners.find((m) => m.ipAddress === miner.ipAddress) + ) { + _minersByModel[minerKey.toString()].miners.push(miner); + } + }); + + return _minersByModel; + }, [miners]); + + const modelEntries = Object.values(minersByModel); + + return ( +
+
+
{ + const totalMinerCount = modelEntries.reduce((total, item) => total + item.miners.length, 0); + if (miners.length === 0 && skeletonVisible) return "Finding miners on your network"; + if (miners.length === 0) return "No miners found"; + if (isScanning) return `Finding miners on your network... ${totalMinerCount} found so far`; + return `${totalMinerCount} miners found on your network`; + })()} + titleSize="text-heading-300" + description={ + miners.length === 0 && skeletonVisible ? undefined : ( + <> + {miners.length === 0 + ? "Try rescanning or check that your miners are powered on and connected to the network." + : "Specify which miners to add to your fleet. All miners are selected by default."} +
+ You can always add more miners to this network later. + + ) + } + /> +
+
+
+ 0} /> + {modelEntries.map((model, index) => ( + + +
+ {isProtoRig(model.manufacturer) ? : } +
+
+ {model.manufacturer} {model.model} +
+ {supportsAutoAuth(model.supportedAuthenticationMethods) ? ( +
Authenticated with default username/password
+ ) : ( +
You will need to log in after setup
+ )} +
+
+ +
+ {model.miners.filter((miner) => !deselectedMiners.includes(miner.deviceIdentifier)).length} miners +
+
+ {modelEntries.length > index + 1 && } +
+ ))} +
+
+
+ ); +}; + +export default FoundMiners; diff --git a/client/src/protoFleet/features/onboarding/components/Miners/FoundMinersModal.stories.tsx b/client/src/protoFleet/features/onboarding/components/Miners/FoundMinersModal.stories.tsx new file mode 100644 index 000000000..31e3f6ecc --- /dev/null +++ b/client/src/protoFleet/features/onboarding/components/Miners/FoundMinersModal.stories.tsx @@ -0,0 +1,78 @@ +import { useState } from "react"; +import { action } from "storybook/actions"; +import FoundMinersModal from "./FoundMinersModal"; +import type { MinerWithSelected } from "./types"; + +export default { + title: "Proto Fleet/Onboarding/FoundMinersModal", + component: FoundMinersModal, +}; + +const mockMiners = [ + { + $typeName: "pairing.v1.Device" as const, + deviceIdentifier: "miner-001", + model: "S19 Pro", + ipAddress: "192.168.1.10", + macAddress: "AA:BB:CC:DD:EE:01", + selected: true, + }, + { + $typeName: "pairing.v1.Device" as const, + deviceIdentifier: "miner-002", + model: "S19 Pro", + ipAddress: "192.168.1.11", + macAddress: "AA:BB:CC:DD:EE:02", + selected: true, + }, + { + $typeName: "pairing.v1.Device" as const, + deviceIdentifier: "miner-003", + model: "S19j Pro", + ipAddress: "192.168.1.12", + macAddress: "AA:BB:CC:DD:EE:03", + selected: true, + }, + { + $typeName: "pairing.v1.Device" as const, + deviceIdentifier: "miner-004", + model: "S19j Pro", + ipAddress: "192.168.1.13", + macAddress: "AA:BB:CC:DD:EE:04", + selected: false, + }, + { + $typeName: "pairing.v1.Device" as const, + deviceIdentifier: "miner-005", + model: "S19 XP", + ipAddress: "192.168.1.14", + macAddress: "AA:BB:CC:DD:EE:05", + selected: true, + }, +] as MinerWithSelected[]; + +export const Default = () => { + const [open, setOpen] = useState(true); + + return ( + <> + {!open && ( +
+ +
+ )} + action("setDeselectedMiners")(deselected)} + onDismiss={() => { + action("onDismiss")(); + setOpen(false); + }} + /> + + ); +}; diff --git a/client/src/protoFleet/features/onboarding/components/Miners/FoundMinersModal.tsx b/client/src/protoFleet/features/onboarding/components/Miners/FoundMinersModal.tsx new file mode 100644 index 000000000..5ee8aab97 --- /dev/null +++ b/client/src/protoFleet/features/onboarding/components/Miners/FoundMinersModal.tsx @@ -0,0 +1,109 @@ +import { Dispatch, SetStateAction, useCallback, useMemo, useState } from "react"; +import type { MinerWithSelected, MinerWithSelectedAndAction } from "./types"; +import { Device } from "@/protoFleet/api/generated/pairing/v1/pairing_pb"; +import { createModelFilter, filterByModel } from "@/protoFleet/utils/minerFilters"; +import { sizes, variants } from "@/shared/components/Button"; +import List from "@/shared/components/List"; +import { ActiveFilters } from "@/shared/components/List/Filters/types"; +import Modal, { ModalSelectAllFooter } from "@/shared/components/Modal"; + +const activeCols = ["model", "ipAddress"] as (keyof MinerWithSelectedAndAction)[]; + +const minerColTitles = { + model: "Model", + ipAddress: "IP address", +} as { + [key in (typeof activeCols)[number]]: string; +}; + +const colConfig = { + model: { + width: "w-full pr-10", + }, + ipAddress: { + width: "w-full pr-10", + }, +}; + +type FoundMinersModalProps = { + open?: boolean; + miners: MinerWithSelected[]; + models: string[]; + setDeselectedMiners: Dispatch>; + onDismiss: () => void; +}; + +const FoundMinersModal = ({ open, miners, models, setDeselectedMiners, onDismiss }: FoundMinersModalProps) => { + const [activeFilters, setActiveFilters] = useState({ + buttonFilters: [], + dropdownFilters: {}, + }); + + const selectedMiners = useMemo(() => { + return miners.filter((miner) => miner.selected).map((miner) => miner.deviceIdentifier); + }, [miners]); + + // Since were keeping deslected miners as state in parent component + // we need to define a setSelectedMiners function that will update + // the deselected miners based on the selected miners + const setSelectedMiners = useCallback( + (selected: MinerWithSelected["deviceIdentifier"][]) => { + const deselected = miners + .filter((miner) => !selected.includes(miner.deviceIdentifier)) + .map((miner) => miner.deviceIdentifier); + + setDeselectedMiners(deselected); + }, + [miners, setDeselectedMiners], + ); + + const modelFilter = useMemo(() => createModelFilter(models), [models]); + + const filteredMiners = useMemo(() => { + return miners.filter((miner) => filterByModel(miner, activeFilters)); + }, [miners, activeFilters]); + + return ( + +
+ + filters={[modelFilter]} + filterItem={filterByModel} + onFilterChange={setActiveFilters} + filterSize={sizes.compact} + activeCols={activeCols} + colTitles={minerColTitles} + colConfig={colConfig} + items={miners} + itemKey="deviceIdentifier" + itemSelectable + customSelectedItems={selectedMiners} + customSetSelectedItems={setSelectedMiners} + containerClassName="max-h-[50vh]" + overflowContainer={true} + stickyBgColor="bg-surface-elevated-base" + /> +
+ setSelectedMiners(filteredMiners.map((miner) => miner.deviceIdentifier))} + onSelectNone={() => setSelectedMiners([])} + /> +
+ ); +}; + +export default FoundMinersModal; diff --git a/client/src/protoFleet/features/onboarding/components/Miners/Miners.stories.tsx b/client/src/protoFleet/features/onboarding/components/Miners/Miners.stories.tsx new file mode 100644 index 000000000..3c8eff1d2 --- /dev/null +++ b/client/src/protoFleet/features/onboarding/components/Miners/Miners.stories.tsx @@ -0,0 +1,56 @@ +import { useState } from "react"; +import { action } from "storybook/actions"; +import MinersComponent from "./Miners"; +import { Device } from "@/protoFleet/api/generated/pairing/v1/pairing_pb"; + +type MinersProps = { + minersCount: number; +}; + +export const Miners = ({ minersCount }: MinersProps) => { + const [miners] = useState([ + ...Array.from( + { length: 1000 }, + (_, i) => + ({ + $typeName: "pairing.v1.Device", + macAddress: `0d:04:8a:54:fa:${(i + 10).toString(16).padStart(2, "0")}`, + deviceIdentifier: `5440...88${(i + 10).toString().padStart(2, "0")}`, + }) as Device, + ), + ]); + + return ( +
+ null} + onContinue={action("continue setup")} + networkInfoPending={false} + scanAvailable + onRescan={action("rescan network")} + /> +
+ ); +}; + +export default { + title: "Proto Fleet/Onboarding/Miners", + args: { + minersCount: 10, + }, + argTypes: { + minersCount: { + control: { + type: "range", + min: 1, + max: 1000, + step: 1, + }, + }, + }, +}; diff --git a/client/src/protoFleet/features/onboarding/components/Miners/Miners.tsx b/client/src/protoFleet/features/onboarding/components/Miners/Miners.tsx new file mode 100644 index 000000000..0a52d7a04 --- /dev/null +++ b/client/src/protoFleet/features/onboarding/components/Miners/Miners.tsx @@ -0,0 +1,451 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import clsx from "clsx"; +import FoundMiners from "./FoundMiners"; +import FoundMinersModal from "./FoundMinersModal"; +import { MinerDiscoveryMode } from "./types"; +import ValidationErrorDialog from "./ValidationErrorDialog"; +import { Device } from "@/protoFleet/api/generated/pairing/v1/pairing_pb"; +import { Dismiss, LogoAlt } from "@/shared/assets/icons"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import Dialog from "@/shared/components/Dialog"; +import Header from "@/shared/components/Header"; +import Input from "@/shared/components/Input"; +import Modal from "@/shared/components/Modal"; +import PageOverlay from "@/shared/components/PageOverlay"; +import Textarea from "@/shared/components/Textarea"; +import { CategorizedInvalidEntries, ManualDiscoveryTargets, parseManualTargets } from "@/shared/utils/networkDiscovery"; + +interface MinersProps { + scanDiscoveryPending: boolean; + ipListDiscoveryPending: boolean; + pairingPending: boolean; + networkInfoPending: boolean; + scanAvailable: boolean; + foundMiners: Device[]; + onCancelScan: () => void; + onManualDiscover: (targets: ManualDiscoveryTargets) => void; + onContinue: (selectedMinerIdentifiers: string[]) => void; + onRescan: () => void; + onForemanImport?: (apiKey: string, clientId: string) => void; + foremanImportPending?: boolean; + mode?: MinerDiscoveryMode; +} + +// Minimum time to show the loading animation in milliseconds (only for network scan) +const MIN_LOADING_TIME = 2000; + +const Miners = ({ + scanDiscoveryPending, + ipListDiscoveryPending, + pairingPending, + networkInfoPending, + scanAvailable, + foundMiners, + onCancelScan, + onManualDiscover, + onContinue, + onRescan, + onForemanImport, + foremanImportPending = false, + mode = "onboarding", +}: MinersProps) => { + const [deselectedMiners, setDeselectedMiners] = useState([]); + const loadingTimeoutId = useRef | null>(null); + const [showScanLoading, setShowScanLoading] = useState(false); + const [textareaValue, setTextareaValue] = useState(""); + const [ipListError, setIpListError] = useState(false); + const [showModal, setShowModal] = useState(false); + const [showFoundMinersModal, setShowFoundMinersModal] = useState(false); + const [activeStep, setActiveStep] = useState<"findMiners" | "pairing">("findMiners"); + const [showValidationErrorDialog, setShowValidationErrorDialog] = useState(false); + const [categorizedInvalidEntries, setCategorizedInvalidEntries] = useState(null); + const [pendingValidTargets, setPendingValidTargets] = useState(null); + const [showForemanModal, setShowForemanModal] = useState(false); + const [foremanApiKey, setForemanApiKey] = useState(""); + const [foremanClientId, setForemanClientId] = useState(""); + + const discoveryPending = scanDiscoveryPending || ipListDiscoveryPending; + const showLoadingSkeleton = showScanLoading || discoveryPending; + const displayMiners = useMemo(() => { + const seen = new Set(); + + return foundMiners.filter((miner) => { + const identity = miner.ipAddress || miner.deviceIdentifier; + if (!identity) { + return true; + } + if (seen.has(identity)) { + return false; + } + seen.add(identity); + return true; + }); + }, [foundMiners]); + const selectedDisplayMiners = displayMiners.filter((miner) => !deselectedMiners.includes(miner.deviceIdentifier)); + + // Handle loading state with minimum display time + useEffect(() => { + if (discoveryPending) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setShowScanLoading(true); + } else { + loadingTimeoutId.current = setTimeout(() => { + setShowScanLoading(false); + }, MIN_LOADING_TIME); + } + + return () => { + if (loadingTimeoutId.current) { + clearTimeout(loadingTimeoutId.current); + loadingTimeoutId.current = null; + } + }; + }, [discoveryPending]); + + function handleIpAddressChange(newValue: string) { + setTextareaValue(newValue); + if (ipListError) { + setIpListError(false); + } + } + + function handleManualDiscovery() { + const { targets, invalidEntries, categorizedInvalidEntries: categorized } = parseManualTargets(textareaValue); + const hasTargets = targets.ipAddresses.length + targets.subnets.length + targets.ipRanges.length > 0; + + if (!hasTargets && invalidEntries.length === 0) { + setIpListError("Enter at least one IP address, hostname, subnet, or IP range."); + return false; + } + + if (!hasTargets && invalidEntries.length > 0) { + setCategorizedInvalidEntries(categorized); + setPendingValidTargets(null); + setShowValidationErrorDialog(true); + return false; + } + + if (invalidEntries.length > 0) { + setCategorizedInvalidEntries(categorized); + setPendingValidTargets(targets); + setShowValidationErrorDialog(true); + return false; + } + + setIpListError(false); + onManualDiscover(targets); + return true; + } + + function handleBackToEditing() { + setShowValidationErrorDialog(false); + + if (categorizedInvalidEntries) { + const allInvalid = [ + ...categorizedInvalidEntries.ipAddresses, + ...categorizedInvalidEntries.ipRanges, + ...categorizedInvalidEntries.subnets, + ].join(", "); + + setIpListError(`Check the format of the following and retry:\n${allInvalid}`); + } + + setCategorizedInvalidEntries(null); + setPendingValidTargets(null); + } + + function handleContinueAnyway() { + setShowValidationErrorDialog(false); + setCategorizedInvalidEntries(null); + + if (pendingValidTargets) { + setIpListError(false); + onManualDiscover(pendingValidTargets); + setActiveStep("pairing"); + setShowModal(true); + } + + setPendingValidTargets(null); + } + + function handleScanCancel() { + setShowScanLoading(false); + if (loadingTimeoutId.current) { + clearTimeout(loadingTimeoutId.current); + loadingTimeoutId.current = null; + } + onCancelScan(); + } + + return ( +
+ + + {mode === "onboarding" && ( +
+
+
+ +
+
+
+ +
+
+
+ )} + + +
+
, + } + : { + icon: , + iconAriaLabel: "Close add miners", + iconOnClick: () => { + handleScanCancel(); + setActiveStep("findMiners"); + setShowModal(false); + }, + })} + inline + buttons={ + showLoadingSkeleton && displayMiners.length === 0 + ? [] + : [ + { + variant: variants.secondary, + onClick: () => { + setDeselectedMiners([]); + onRescan(); + }, + text: discoveryPending ? "Scanning" : "Rescan network", + disabled: pairingPending || discoveryPending, + loading: discoveryPending, + className: clsx({ + hidden: activeStep !== "pairing", + }), + }, + { + variant: variants.secondary, + onClick: () => { + setShowFoundMinersModal(true); + }, + text: "Choose miners", + disabled: pairingPending, + className: clsx({ + hidden: activeStep !== "pairing" || displayMiners.length <= 1, + }), + }, + { + variant: variants.primary, + loading: pairingPending, + onClick: () => { + const selectedMinerIdentifiers = selectedDisplayMiners.map((miner) => miner.deviceIdentifier); + onContinue(selectedMinerIdentifiers); + }, + disabled: pairingPending || selectedDisplayMiners.length === 0, + text: pairingPending + ? `Adding ${selectedDisplayMiners.length} miners...` + : `Continue with ${selectedDisplayMiners.length} miners`, + className: clsx({ + hidden: activeStep !== "pairing" || displayMiners.length === 0, + }), + }, + ] + } + /> + {activeStep === "findMiners" && ( +
+
+

+ Scan your network or provide miner IP addresses and hostnames to find miners to add to your fleet. +

+

Note that you can add more miners and adjust security settings after setup.

+ + } + titleSize="text-heading-300" + inline + /> + +
+
+
+
+ +
+
+ + {onForemanImport && ( +
+
+
+ +
+
+ )} +
+ +
+
+
+
+