diff --git a/.envrc b/.envrc new file mode 100644 index 000000000..6bc8abe38 --- /dev/null +++ b/.envrc @@ -0,0 +1,11 @@ +# Hyperloop H10 Development Environment +# +# This file is used by direnv to automatically load the development environment +# when you enter this directory. +# +# To use: +# 1. Install direnv: https://direnv.net/ +# 2. Run: direnv allow +# +# For pure shell (default): +use nix diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 3b32b8db0..000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,21 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "npm" - directory: "/" - schedule: - interval: "weekly" - target-branch: "develop" - # Ignore all patch updates (e.g. 1.0.1 -> 1.0.2) - ignore: - - dependency-name: "*" - update-types: ["version-update:semver-patch"] - - - package-ecosystem: "npm" - directory: "/" - schedule: - interval: "weekly" - target-branch: "main" - # Ignore all patch updates (e.g. 1.0.1 -> 1.0.2) - ignore: - - dependency-name: "*" - update-types: ["version-update:semver-patch"] diff --git a/.github/ex_workflows/build-backend.yaml b/.github/ex_workflows/build-backend.yaml new file mode 100644 index 000000000..663769996 --- /dev/null +++ b/.github/ex_workflows/build-backend.yaml @@ -0,0 +1,134 @@ +name: Build backend + +on: + workflow_dispatch: + pull_request: + paths: + - backend/** + +jobs: + build-backend-linux: + name: "Build backend for linux" + runs-on: ubuntu-latest + + # Runs on alpine because it is easier to statically link the library + container: + image: golang:alpine + + env: + BACKEND_DIR: ./backend + + steps: + - name: "Install packages" + run: apk update && apk add --no-cache libpcap-dev musl-dev gcc go + + - uses: actions/checkout@v4 + with: + sparse-checkout: backend + + - name: "Create output path" + working-directory: "${{env.BACKEND_DIR}}" + run: mkdir ./output + + - name: "Build (64 bit)" + working-directory: "${{env.BACKEND_DIR}}/cmd" + env: + CGO_ENABLED: 1 + GOARCH: amd64 + GOOS: linux + run: | + go build -ldflags '-linkmode external -extldflags "-static"' -o ../output/backend-linux-64 + + - name: "Upload build" + uses: actions/upload-artifact@v4 + with: + name: backend-linux + path: "${{env.BACKEND_DIR}}/output/*" + retention-days: 3 + compression-level: 9 + + build-backend-windows: + name: "Build backend for windows" + runs-on: windows-latest + + env: + BACKEND_DIR: ".\\backend" + + steps: + - uses: actions/checkout@v3 + + - name: "Setup Go" + uses: actions/setup-go@v4 + with: + go-version: "1.21.3" + cache-dependency-path: "${{env.BACKEND_DIR}}\\go.sum" + + - name: "Create output path" + working-directory: "${{env.BACKEND_DIR}}" + run: mkdir .\output + + - name: "Build (64 bit)" + working-directory: "${{env.BACKEND_DIR}}\\cmd" + env: + CGO_ENABLED: 1 + GOARCH: amd64 + GOOS: windows + run: | + go build -o ..\output\backend-windows-64.exe + + - name: "Upload build" + uses: actions/upload-artifact@v4 + with: + name: backend-windows + path: "${{env.BACKEND_DIR}}\\output\\*" + retention-days: 3 + compression-level: 9 + + build-backend-mac: + name: "Build backend for macOS" + runs-on: macos-latest + + env: + BACKEND_DIR: ./backend + + steps: + - name: "Install packages" + run: brew install libpcap + + - name: "Setup Go" + uses: actions/setup-go@v4 + with: + go-version: "1.21.3" + cache-dependency-path: "${{env.BACKEND_DIR}}/go.sum" + + - uses: actions/checkout@v3 + + - name: "Create output path" + working-directory: "${{env.BACKEND_DIR}}" + run: mkdir ./output + + - name: "Build (64 bit)" + working-directory: "${{env.BACKEND_DIR}}/cmd" + env: + CGO_ENABLED: 1 + GOARCH: amd64 + GOOS: darwin + run: | + go build -o ../output/backend-macos-64 + + - name: "Build (apple 64 bit)" + working-directory: "${{env.BACKEND_DIR}}/cmd" + env: + CGO_ENABLED: 1 + GOARCH: arm64 + GOOS: darwin + run: | + go build -o ../output/backend-macos-m1-64 + + - name: "Upload build" + uses: actions/upload-artifact@v4 + with: + name: backend-macos + path: "${{env.BACKEND_DIR}}/output/*" + retention-days: 3 + compression-level: 9 diff --git a/.github/ex_workflows/build-control-station.yaml b/.github/ex_workflows/build-control-station.yaml new file mode 100644 index 000000000..265707b12 --- /dev/null +++ b/.github/ex_workflows/build-control-station.yaml @@ -0,0 +1,48 @@ +name: Build control station + +on: + workflow_dispatch: + pull_request: + paths: + - control-station/** + - common-front/** + +jobs: + build-control-station: + name: 'Build control station' + runs-on: ubuntu-latest + + env: + FRONTEND_DIR: ./control-station + COMMON_DIR: ./common-front + + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: | + control-station + common-front + + - name: 'Install common front dependencies' + working-directory: '${{env.COMMON_DIR}}' + run: npm install + + - name: 'Build common front' + working-directory: '${{env.COMMON_DIR}}' + run: npm run build + + - name: 'Install control station dependencies' + working-directory: '${{env.FRONTEND_DIR}}' + run: npm install + + - name: 'Build control station' + working-directory: '${{env.FRONTEND_DIR}}' + run: npm run build + + - name: 'Upload build' + uses: actions/upload-artifact@v4 + with: + name: control-station + path: '${{env.FRONTEND_DIR}}/static/*' + retention-days: 3 + compression-level: 9 diff --git a/.github/ex_workflows/build-ethernet-view.yaml b/.github/ex_workflows/build-ethernet-view.yaml new file mode 100644 index 000000000..face89659 --- /dev/null +++ b/.github/ex_workflows/build-ethernet-view.yaml @@ -0,0 +1,51 @@ +name: Build ethernet view + +on: + workflow_dispatch: + pull_request: + paths: + - ethernet-view/** + - common-front/** + +jobs: + build-ethernet-view: + name: "Build ethernet view" + runs-on: ubuntu-latest + + env: + FRONTEND_DIR: ./ethernet-view + COMMON_DIR: ./common-front + + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: | + ethernet-view + common-front + + - name: "Install common front dependencies" + working-directory: "${{env.COMMON_DIR}}" + run: npm install + + + - name: "Build common front" + working-directory: "${{env.COMMON_DIR}}" + run: npm run build + + - name: "Install ethernet view dependencies" + working-directory: "${{env.FRONTEND_DIR}}" + run: npm install + + + - name: "Build ethernet view" + working-directory: "${{env.FRONTEND_DIR}}" + run: npm run build + + + - name: "Upload build" + uses: actions/upload-artifact@v4 + with: + name: ethernet-view + path: "${{env.FRONTEND_DIR}}/static/*" + retention-days: 3 + compression-level: 9 diff --git a/.github/ex_workflows/build-updater.yaml b/.github/ex_workflows/build-updater.yaml new file mode 100644 index 000000000..ce9af5e6b --- /dev/null +++ b/.github/ex_workflows/build-updater.yaml @@ -0,0 +1,131 @@ +name: Build updater + +on: + workflow_dispatch: + pull_request: + paths: + - updater/** + +jobs: + build-updater-linux: + name: Build updater for Linux + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Mark workspace as safe + run: git config --global --add safe.directory $GITHUB_WORKSPACE + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: "1.21.3" + cache-dependency-path: updater/go.sum + + - name: Ensure dependencies + working-directory: updater + run: go mod tidy + + - name: Make output dir + run: mkdir -p updater/output + + - name: Build (linux/amd64) + run: | + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -o updater/output/updater-linux-64 ./updater + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: updater-linux + path: updater/output/* + + build-updater-windows: + name: Build updater for Windows + runs-on: windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Mark workspace as safe + shell: pwsh + run: git config --global --add safe.directory $Env:GITHUB_WORKSPACE + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: "1.21.3" + cache-dependency-path: updater\go.sum + + - name: Ensure dependencies + working-directory: updater + shell: pwsh + run: go mod tidy + + - name: Make output dir + shell: pwsh + run: New-Item -ItemType Directory -Path updater\output -Force + + - name: Build (windows/amd64) + shell: pwsh + run: | + $Env:CGO_ENABLED='0' + go build -o updater\output\updater-windows-64.exe ./updater + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: updater-windows + path: updater\output\* + + build-updater-mac: + name: Build updater for macOS + runs-on: macos-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Mark workspace as safe + run: git config --global --add safe.directory $GITHUB_WORKSPACE + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: "1.21.3" + cache-dependency-path: updater/go.sum + + - name: Ensure dependencies + working-directory: updater + run: go mod tidy + + - name: Make output dir + run: mkdir -p updater/output + + - name: Build (macOS Intel) + run: | + CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 \ + go build -o updater/output/updater-macos-64 ./updater + + - name: Build (macOS ARM64) + run: | + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 \ + go build -o updater/output/updater-macos-m1-64 ./updater + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: updater-macos + path: updater/output/* diff --git a/.github/ex_workflows/release.yaml b/.github/ex_workflows/release.yaml new file mode 100644 index 000000000..ce8c2c6ea --- /dev/null +++ b/.github/ex_workflows/release.yaml @@ -0,0 +1,595 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Release version' + required: true + default: '' + draft: + description: 'Create as draft release' + type: boolean + default: true + release: + types: [created] + +jobs: + build-backend: + name: Build Backend + runs-on: ${{ matrix.os }} + container: ${{ matrix.container }} + strategy: + matrix: + include: + - os: ubuntu-latest + name: linux + container: + image: golang:alpine + setup: | + apk update && apk add --no-cache libpcap-dev musl-dev gcc go bash + build_cmd: | + cd backend/cmd + CGO_ENABLED=1 GOARCH=amd64 GOOS=linux go build -ldflags '-linkmode external -extldflags "-static"' -o ../output/backend-linux-amd64 + artifact_name: backend-linux + + - os: windows-latest + name: windows + setup: | + echo "Setting up Windows environment" + build_cmd: | + cd backend\cmd + $env:CGO_ENABLED="1" + $env:GOARCH="amd64" + $env:GOOS="windows" + go build -o ..\output\backend-windows-amd64.exe + artifact_name: backend-windows + + - os: macos-latest + name: macos + setup: | + brew install libpcap + build_cmd: | + cd backend/cmd + CGO_ENABLED=1 GOARCH=amd64 GOOS=darwin go build -o ../output/backend-macos-amd64 + CGO_ENABLED=1 GOARCH=arm64 GOOS=darwin go build -o ../output/backend-macos-arm64 + artifact_name: backend-macos + + steps: + - uses: actions/checkout@v4 + + - name: Setup environment + run: ${{ matrix.setup }} + + - name: Setup Go + if: matrix.os != 'ubuntu-latest' + uses: actions/setup-go@v4 + with: + go-version: "1.21.3" + cache-dependency-path: backend/go.sum + + - name: Create output directory (Linux) + if: matrix.os == 'ubuntu-latest' + run: mkdir -p backend/output + shell: sh + + - name: Create output directory (macOS) + if: matrix.os == 'macos-latest' + run: mkdir -p backend/output + shell: bash + + - name: Create output directory (Windows) + if: matrix.os == 'windows-latest' + run: mkdir -p backend/output + shell: bash + + - name: Build backend (Linux) + if: matrix.os == 'ubuntu-latest' + run: ${{ matrix.build_cmd }} + shell: sh + + - name: Build backend (macOS) + if: matrix.os == 'macos-latest' + run: ${{ matrix.build_cmd }} + shell: bash + + - name: Build backend (Windows) + if: matrix.os == 'windows-latest' + run: ${{ matrix.build_cmd }} + shell: pwsh + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact_name }} + path: backend/output/* + retention-days: 7 + compression-level: 9 + + build-frontend: + name: Build Frontends + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + + - name: Build common front dependencies + working-directory: ./common-front + run: | + npm install + npm run build + + - name: Build ethernet view + working-directory: ./ethernet-view + run: | + npm install + npm run build + + - name: Upload ethernet view artifact + uses: actions/upload-artifact@v4 + with: + name: ethernet-view + path: ethernet-view/static/* + retention-days: 7 + compression-level: 9 + + - name: Build control station + working-directory: ./control-station + run: | + npm install + npm run build + + - name: Upload control station artifact + uses: actions/upload-artifact@v4 + with: + name: control-station + path: control-station/static/* + retention-days: 7 + compression-level: 9 + + build-updater: + name: Build Updater + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-latest + name: linux + build_cmd: | + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o updater/output/updater-linux-amd64 ./updater + artifact_name: updater-linux + + - os: windows-latest + name: windows + build_cmd: | + $Env:CGO_ENABLED='0' + $Env:GOOS='windows' + $Env:GOARCH='amd64' + go build -o updater\output\updater-windows-amd64.exe ./updater + artifact_name: updater-windows + + - os: macos-latest + name: macos + build_cmd: | + CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o updater/output/updater-macos-amd64 ./updater + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o updater/output/updater-macos-arm64 ./updater + artifact_name: updater-macos + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Mark workspace as safe + run: git config --global --add safe.directory $GITHUB_WORKSPACE + shell: bash + + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: "1.21.3" + cache-dependency-path: updater/go.sum + + - name: Ensure dependencies (Linux) + if: matrix.os == 'ubuntu-latest' + working-directory: updater + run: go mod tidy + shell: bash + + - name: Ensure dependencies (macOS) + if: matrix.os == 'macos-latest' + working-directory: updater + run: go mod tidy + shell: bash + + - name: Ensure dependencies (Windows) + if: matrix.os == 'windows-latest' + working-directory: updater + run: go mod tidy + shell: pwsh + + - name: Create output directory (Linux) + if: matrix.os == 'ubuntu-latest' + run: mkdir -p updater/output + shell: bash + + - name: Create output directory (macOS) + if: matrix.os == 'macos-latest' + run: mkdir -p updater/output + shell: bash + + - name: Create output directory (Windows) + if: matrix.os == 'windows-latest' + run: mkdir -p updater/output + shell: bash + + - name: Build updater (Linux) + if: matrix.os == 'ubuntu-latest' + run: ${{ matrix.build_cmd }} + shell: bash + + - name: Build updater (macOS) + if: matrix.os == 'macos-latest' + run: ${{ matrix.build_cmd }} + shell: bash + + - name: Build updater (Windows) + if: matrix.os == 'windows-latest' + run: ${{ matrix.build_cmd }} + shell: pwsh + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact_name }} + path: updater/output/* + retention-days: 7 + compression-level: 9 + + build-testadj: + name: Build testadj executable + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-latest + name: linux + setup: | + python3 -m pip install pyinstaller + build_cmd: | + cd backend/cmd + pyinstaller --onefile --name testadj-linux testadj.py + artifact_name: testadj-linux + artifact_path: backend/cmd/dist/testadj-linux + + - os: windows-latest + name: windows + setup: | + python -m pip install pyinstaller + build_cmd: | + cd backend\cmd + pyinstaller --onefile --name testadj-windows testadj.py + artifact_name: testadj-windows + artifact_path: backend\cmd\dist\testadj-windows.exe + + - os: macos-latest + name: macos + setup: | + python3 -m pip install pyinstaller + build_cmd: | + cd backend/cmd + pyinstaller --onefile --name testadj-macos testadj.py + artifact_name: testadj-macos + artifact_path: backend/cmd/dist/testadj-macos + + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' + + - name: Setup environment + run: ${{ matrix.setup }} + shell: bash + + - name: Build testadj (Linux) + if: matrix.os == 'ubuntu-latest' + run: ${{ matrix.build_cmd }} + shell: bash + + - name: Build testadj (macOS) + if: matrix.os == 'macos-latest' + run: ${{ matrix.build_cmd }} + shell: bash + + - name: Build testadj (Windows) + if: matrix.os == 'windows-latest' + run: ${{ matrix.build_cmd }} + shell: pwsh + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact_name }} + path: ${{ matrix.artifact_path }} + retention-days: 7 + compression-level: 9 + + prepare-common-files: + name: Prepare Common Files + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Create common files directory + run: mkdir -p common-files + + - name: Copy config.toml + run: cp backend/cmd/config.toml common-files/ + + + - name: Copy README.md + run: cp README.md common-files/ + + - name: Create VERSION.txt + run: | + VERSION="${{ github.event.inputs.version }}" + echo "$VERSION" > common-files/VERSION.txt + + - name: Upload common files artifact + uses: actions/upload-artifact@v4 + with: + name: common-files + path: common-files/* + retention-days: 7 + compression-level: 9 + + package-release: + name: Package Release + needs: [build-backend, build-frontend, build-updater, build-testadj, prepare-common-files] + runs-on: ubuntu-latest + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Create release directories for each platform + run: | + VERSION="${{ github.event.inputs.version }}" + + # Create directory structure for each platform + mkdir -p release-linux + mkdir -p release-windows + mkdir -p release-macos + mkdir -p release-macos-arm64 + + - name: Organize Linux release files + run: | + VERSION="${{ github.event.inputs.version }}" + + # Copy Linux backend + cp artifacts/backend-linux/backend-linux-amd64 release-linux/backend + + # Copy Linux updater + cp artifacts/updater-linux/updater-linux-amd64 release-linux/updater + + # Copy Linux testadj + cp artifacts/testadj-linux/testadj-linux release-linux/testadj + + # Copy frontends + mkdir -p release-linux/ethernet-view + mkdir -p release-linux/control-station + cp -r artifacts/ethernet-view/* release-linux/ethernet-view/ + cp -r artifacts/control-station/* release-linux/control-station/ + + # Copy common files + cp -r artifacts/common-files/* release-linux/ + + # Set executable permissions + chmod +x release-linux/backend release-linux/updater release-linux/testadj + + # Create Linux release archive + cd release-linux + tar -czf ../linux-$VERSION.tar.gz . + + - name: Organize Windows release files + run: | + VERSION="${{ github.event.inputs.version }}" + + # Copy Windows backend + cp artifacts/backend-windows/backend-windows-amd64.exe release-windows/backend.exe + + # Copy Windows updater + cp artifacts/updater-windows/updater-windows-amd64.exe release-windows/updater.exe + + # Copy Windows testadj + cp artifacts/testadj-windows/testadj-windows.exe release-windows/testadj.exe + + # Copy frontends + mkdir -p release-windows/ethernet-view + mkdir -p release-windows/control-station + cp -r artifacts/ethernet-view/* release-windows/ethernet-view/ + cp -r artifacts/control-station/* release-windows/control-station/ + + # Copy common files + cp -r artifacts/common-files/* release-windows/ + + # Create Windows release archive + cd release-windows + zip -r ../windows-$VERSION.zip . + + - name: Organize macOS Intel release files + run: | + VERSION="${{ github.event.inputs.version }}" + + # Copy macOS Intel backend + cp artifacts/backend-macos/backend-macos-amd64 release-macos/backend + + # Copy macOS Intel updater + cp artifacts/updater-macos/updater-macos-amd64 release-macos/updater + + # Copy macOS testadj + cp artifacts/testadj-macos/testadj-macos release-macos/testadj + + # Copy frontends + mkdir -p release-macos/ethernet-view + mkdir -p release-macos/control-station + cp -r artifacts/ethernet-view/* release-macos/ethernet-view/ + cp -r artifacts/control-station/* release-macos/control-station/ + + # Copy common files + cp -r artifacts/common-files/* release-macos/ + + # Set executable permissions + chmod +x release-macos/backend release-macos/updater release-macos/testadj + + # Create macOS Intel release archive + cd release-macos + tar -czf ../macos-intel-$VERSION.tar.gz . + + - name: Organize macOS ARM64 release files + run: | + VERSION="${{ github.event.inputs.version }}" + + # Copy macOS ARM64 backend + cp artifacts/backend-macos/backend-macos-arm64 release-macos-arm64/backend + + # Copy macOS ARM64 updater + cp artifacts/updater-macos/updater-macos-arm64 release-macos-arm64/updater + + # Copy macOS testadj + cp artifacts/testadj-macos/testadj-macos release-macos-arm64/testadj + + # Copy frontends + mkdir -p release-macos-arm64/ethernet-view + mkdir -p release-macos-arm64/control-station + cp -r artifacts/ethernet-view/* release-macos-arm64/ethernet-view/ + cp -r artifacts/control-station/* release-macos-arm64/control-station/ + + # Copy common files + cp -r artifacts/common-files/* release-macos-arm64/ + + # Set executable permissions + chmod +x release-macos-arm64/backend release-macos-arm64/updater release-macos-arm64/testadj + + # Create macOS ARM64 release archive + cd release-macos-arm64 + tar -czf ../macos-arm64-$VERSION.tar.gz . + + - name: Upload release packages + uses: actions/upload-artifact@v4 + with: + name: releases + path: | + *.tar.gz + *.zip + retention-days: 7 + compression-level: 9 + + - name: Create Release + if: github.event_name == 'workflow_dispatch' + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: v${{ github.event.inputs.version }} + release_name: Release ${{ github.event.inputs.version }} + draft: ${{ github.event.inputs.draft }} + prerelease: false + + - name: Upload Linux package to release + if: github.event_name == 'workflow_dispatch' + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./linux-${{ github.event.inputs.version }}.tar.gz + asset_name: linux-${{ github.event.inputs.version }}.tar.gz + asset_content_type: application/gzip + + - name: Upload Windows package to release + if: github.event_name == 'workflow_dispatch' + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./windows-${{ github.event.inputs.version }}.zip + asset_name: windows-${{ github.event.inputs.version }}.zip + asset_content_type: application/zip + + - name: Upload macOS Intel package to release + if: github.event_name == 'workflow_dispatch' + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./macos-intel-${{ github.event.inputs.version }}.tar.gz + asset_name: macos-intel-${{ github.event.inputs.version }}.tar.gz + asset_content_type: application/gzip + + - name: Upload macOS ARM64 package to release + if: github.event_name == 'workflow_dispatch' + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./macos-arm64-${{ github.event.inputs.version }}.tar.gz + asset_name: macos-arm64-${{ github.event.inputs.version }}.tar.gz + asset_content_type: application/gzip + + - name: Upload Linux package to existing release + if: github.event_name == 'release' + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ github.event.release.upload_url }} + asset_path: ./linux-${{ github.event.release.tag_name }}.tar.gz + asset_name: linux-${{ github.event.release.tag_name }}.tar.gz + asset_content_type: application/gzip + + - name: Upload Windows package to existing release + if: github.event_name == 'release' + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ github.event.release.upload_url }} + asset_path: ./windows-${{ github.event.release.tag_name }}.zip + asset_name: windows-${{ github.event.release.tag_name }}.zip + asset_content_type: application/zip + + - name: Upload macOS Intel package to existing release + if: github.event_name == 'release' + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ github.event.release.upload_url }} + asset_path: ./macos-intel-${{ github.event.release.tag_name }}.tar.gz + asset_name: macos-intel-${{ github.event.release.tag_name }}.tar.gz + asset_content_type: application/gzip + + - name: Upload macOS ARM64 package to existing release + if: github.event_name == 'release' + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ github.event.release.upload_url }} + asset_path: ./macos-arm64-${{ github.event.release.tag_name }}.tar.gz + asset_name: macos-arm64-${{ github.event.release.tag_name }}.tar.gz + asset_content_type: application/gzip \ No newline at end of file diff --git a/.github/ex_workflows/test-backend.yaml b/.github/ex_workflows/test-backend.yaml new file mode 100644 index 000000000..a1f1e780f --- /dev/null +++ b/.github/ex_workflows/test-backend.yaml @@ -0,0 +1,37 @@ +name: Test backend + +on: + push: + paths: + - backend/** + pull_request: + paths: + - backend/** + workflow_dispatch: + +jobs: + test-backend: + name: "Test backend" + runs-on: ubuntu-latest + + env: + BACKEND_DIR: ./backend + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: 1.21 + cache: false + + + - name: Install Dependencies + run: | + sudo apt-get update && sudo apt-get install -y libpcap-dev + + - name: Test with Go + working-directory: "${{env.BACKEND_DIR}}" + run: go test -v -timeout 30s ./... diff --git a/.github/ex_workflows/test-dev-scripts.yaml b/.github/ex_workflows/test-dev-scripts.yaml new file mode 100644 index 000000000..d74a43168 --- /dev/null +++ b/.github/ex_workflows/test-dev-scripts.yaml @@ -0,0 +1,82 @@ +name: Test Development Scripts + +on: + pull_request: + paths: + - scripts/** + workflow_dispatch: + +jobs: + test-dev-scripts: + name: Test Development Scripts + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-latest + name: linux + shell: bash + script: ./scripts/dev.sh + + - os: windows-latest + name: windows-powershell + shell: pwsh + script: .\scripts\dev.ps1 + + - os: windows-latest + name: windows-cmd + shell: cmd + script: scripts\dev.cmd + + - os: macos-latest + name: macos + shell: bash + script: ./scripts/dev.sh + + steps: + - uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: "1.21.3" + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + + - name: Install tmux (Linux/macOS) + if: matrix.os != 'windows-latest' + run: | + if [ "${{ matrix.os }}" = "ubuntu-latest" ]; then + sudo apt-get update && sudo apt-get install -y tmux + elif [ "${{ matrix.os }}" = "macos-latest" ]; then + brew install tmux + fi + shell: bash + + - name: Make script executable (Unix) + if: matrix.os != 'windows-latest' + run: chmod +x scripts/dev.sh + shell: bash + + - name: Test script help/usage + run: ${{ matrix.script }} + shell: ${{ matrix.shell }} + continue-on-error: true + + - name: Test dependency check + run: ${{ matrix.script }} setup + shell: ${{ matrix.shell }} + + - name: Test build command + run: ${{ matrix.script }} build + shell: ${{ matrix.shell }} + continue-on-error: true + + - name: Test backend build (quick test) + run: ${{ matrix.script }} test + shell: ${{ matrix.shell }} + continue-on-error: true \ No newline at end of file diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 2564f79db..2594970f7 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -10,77 +10,238 @@ on: workflow_dispatch: inputs: rebuild-backend: - description: Force rebuild backend + description: Build backend binaries type: boolean - default: false - rebuild-testing-view: - description: Force rebuild testing-view + default: true + rebuild-control-station: + description: Build control station frontend type: boolean - default: false - rebuild-competition-view: - description: Force rebuild competition-view + default: true + rebuild-ethernet-view: + description: Build ethernet view frontend type: boolean - default: false - # workflow_call: - # inputs: - # build-backend: - # description: Build backend binaries - # type: boolean - # default: false - # required: false - # build-testing-view: - # description: Build testing-view - # type: boolean - # default: false - # required: false - # build-competition-view: - # description: Build competition-view - # type: boolean - # default: false - # required: false + default: true + workflow_call: + inputs: + build-backend: + description: Build backend binaries + type: boolean + default: true + required: false + build-control-station: + description: Build control station frontend + type: boolean + default: true + required: false + build-ethernet-view: + description: Build ethernet view frontend + type: boolean + default: true + required: false jobs: - # ------------------------------------------------------------------ - # 1. DETECT CHANGES - # Checks which parts of the codebase changed to avoid unnecessary builds - # ------------------------------------------------------------------ + # Detect what changed detect-changes: name: Detect Changes runs-on: ubuntu-latest + # We can't use this condition here because it would skip the whole job without setting the outputs + # if: github.event_name == 'push' || github.event_name == 'pull_request' + # Insted, we skip steps inside the job outputs: - backend: ${{ steps.filter.outputs.backend == 'true' || github.event.inputs.rebuild-backend == 'true' || inputs.build-backend == true }} - testing-view: ${{ steps.filter.outputs.testing-view == 'true' || github.event.inputs.rebuild-testing-view == 'true' || inputs.build-testing-view == true }} - competition-view: ${{ steps.filter.outputs.competition-view == 'true' || github.event.inputs.rebuild-competition-view == 'true' || inputs.build-competition-view == true }} + backend_needs_rebuild: ${{ steps.changes.outputs.backend_any_changed == 'true' || github.event.inputs.rebuild-backend == 'true' }} + control-station_needs_rebuild: ${{ steps.changes.outputs.control-station_any_changed == 'true' || github.event.inputs.rebuild-control-station == 'true' }} + ethernet-view_needs_rebuild: ${{ steps.changes.outputs.ethernet-view_any_changed == 'true' || github.event.inputs.rebuild-ethernet-view == 'true' }} + common-front_needs_rebuild: ${{ steps.changes.outputs.common-front_any_changed == 'true' || github.event.inputs.rebuild-common-front == 'true' }} steps: - - uses: actions/checkout@v4 + # Only run on push or pull request events + # Skip on workflow_call or workflow_dispatch + - name: Checkout repository + if: github.event_name == 'push' || github.event_name == 'pull_request' + uses: actions/checkout@v4 + with: + fetch-depth: 0 - - uses: dorny/paths-filter@v3 - id: filter + # Only run on push or pull request events + # Skip on workflow_call or workflow_dispatch + - name: Detect changed files + if: github.event_name == 'push' || github.event_name == 'pull_request' + id: changes + uses: tj-actions/changed-files@v41 with: - ref: "production" - filters: | + files_yaml: | backend: - 'backend/**/*' - - 'go.work' - - 'go.work.sum' - testing-view: - - 'frontend/testing-view/**/*' - - 'frontend/frontend-kit/**/*' # Shared lib dependency - - 'pnpm-lock.yaml' - - 'pnpm-workspace.yaml' - competition-view: - - 'frontend/competition-view/**/*' - - 'frontend/frontend-kit/**/*' # Shared lib dependency - - 'pnpm-lock.yaml' - - 'pnpm-workspace.yaml' - - # ------------------------------------------------------------------ - # 2. BUILD BACKEND (MATRIX) - # Builds Go binaries for Linux, Windows, and macOS (Intel & Arm) - # ------------------------------------------------------------------ - build-backend: - name: Backend - ${{ matrix.platform }} + control-station: + - 'control-station/**/*' + ethernet-view: + - 'ethernet-view/**/*' + common-front: + - 'common-front/**/*' + + # We want to set outputs even if the job should be skipped + - name: Set outputs + id: set-outputs + run: | + if [ "${{ github.event_name }}" = "push" ] || [ "${{ github.event_name }}" = "pull_request" ]; then + echo "backend_needs_rebuild=${{ steps.changes.outputs.backend_any_changed == 'true' && 'true' || 'false' }}" >> $GITHUB_OUTPUT + echo "control-station_needs_rebuild=${{ steps.changes.outputs.control-station_any_changed == 'true' || steps.changes.outputs.common-front_any_changed == 'true' && 'true' || 'false' }}" >> $GITHUB_OUTPUT + echo "ethernet-view_needs_rebuild=${{ steps.changes.outputs.ethernet-view_any_changed == 'true' || steps.changes.outputs.common-front_any_changed == 'true' && 'true' || 'false' }}" >> $GITHUB_OUTPUT + echo "common-front_needs_rebuild=${{ steps.changes.outputs.common-front_any_changed == 'true' && 'true' || 'false' }}" >> $GITHUB_OUTPUT + else + # On workflow_call/dispatch, default to false (no changes detected) + echo "backend_needs_rebuild=false" >> $GITHUB_OUTPUT + echo "control-station_needs_rebuild=false" >> $GITHUB_OUTPUT + echo "ethernet-view_needs_rebuild=false" >> $GITHUB_OUTPUT + echo "common-front_needs_rebuild=false" >> $GITHUB_OUTPUT + fi + + - name: Debug changed files + if: github.event_name == 'push' || github.event_name == 'pull_request' + run: | + echo "Changed backend: ${{ steps.changes.outputs.backend_any_changed }}" + echo "Changed control-station: ${{ steps.changes.outputs.control-station_any_changed }}" + echo "Changed ethernet-view: ${{ steps.changes.outputs.ethernet-view_any_changed }}" + echo "Changed common-front: ${{ steps.changes.outputs.common-front_any_changed }}" + + # Download backend from previous build if no changes + download-backend: + name: Download Backend - ${{ matrix.platform }} needs: detect-changes + # It is important to use != 'true' and not == 'false' because if they are no inputs (executed on push or pull request events), + # values for inputs will be undefined which gives false for any condition + # The same thing applies for detect changes outputs, because we skip this job on dispatch and call events + # Thus, it's outputs are undefined + if: | + needs.detect-changes.outputs.backend_needs_rebuild != 'true' && + inputs.build-backend != 'true' + runs-on: ubuntu-latest + # Continue on error is necessary because download-backend job can fail if backend artifact is not found + # and in this case it is not necessarily has to fail the build + continue-on-error: true + outputs: + downloaded: ${{ steps.download.outcome == 'success' }} + strategy: + fail-fast: false + matrix: + platform: [linux, windows, macos-intel, macos-arm64] + + steps: + - name: Download backend from latest build + id: download + uses: dawidd6/action-download-artifact@v3 + with: + workflow: build.yaml + branch: production + workflow_conclusion: "completed" + name: backend-${{ matrix.platform }} + path: backend-artifacts + if_no_artifact_found: fail + + - name: Re-upload backend artifact + if: steps.download.outcome == 'success' + uses: actions/upload-artifact@v4 + with: + name: backend-${{ matrix.platform }} + path: backend-artifacts/* + retention-days: 30 + + - name: Write matrix output + if: always() + uses: cloudposse/github-action-matrix-outputs-write@v1 + with: + matrix-step-name: download-backend + matrix-key: ${{ matrix.platform }} + outputs: | + downloaded: ${{ steps.download.outcome == 'success' }} + + # Aggregate download results + aggregate-downloads: + name: Aggregate Download Results + # Always run to ensure outputs are always available + if: always() + needs: [detect-changes, download-backend] + runs-on: ubuntu-latest + steps: + - name: Set defaults if backend changed + id: set-defaults + run: | + # If backend changed or build-backend is explicitly requested, set defaults and skip aggregation + if [ "${{ needs.detect-changes.outputs.backend_needs_rebuild }}" = "true" ] || [ "${{ inputs.build-backend }}" = "true" ]; then + echo "any_needs_rebuild=false" >> $GITHUB_OUTPUT + echo 'result={"downloaded":{"linux":false,"windows":false,"macos-intel":false,"macos-arm64":false}}' >> $GITHUB_OUTPUT + echo "Backend changed or build requested, using defaults" + fi + shell: bash + + - uses: cloudposse/github-action-matrix-outputs-read@v1 + id: read + if: needs.detect-changes.outputs.backend_needs_rebuild != 'true' && inputs.build-backend != 'true' + with: + matrix-step-name: download-backend + + - name: Check if any platform needs rebuild + if: needs.detect-changes.outputs.backend_needs_rebuild != 'true' && inputs.build-backend != 'true' + id: check-rebuild + run: | + # Parse the result JSON to check if any download failed + RESULT='${{ steps.read.outputs.result }}' + echo "Parsing result: $RESULT" + + # Check if any platform has downloaded=false + # The result format is: {downloaded:{linux:true,windows:false,...}} + if echo "$RESULT" | grep -q '"downloaded".*"linux":false' || \ + echo "$RESULT" | grep -q '"downloaded".*"windows":false' || \ + echo "$RESULT" | grep -q '"downloaded".*"macos-intel":false' || \ + echo "$RESULT" | grep -q '"downloaded".*"macos-arm64":false'; then + echo "any_needs_rebuild=true" >> $GITHUB_OUTPUT + echo "At least one platform download failed, rebuild needed" + else + echo "any_needs_rebuild=false" >> $GITHUB_OUTPUT + echo "All platform downloads succeeded, no rebuild needed" + fi + shell: bash + + - name: Debug aggregate-downloads outputs + if: always() + run: | + echo "=== aggregate-downloads Debug ===" + echo "inputs.build-backend: ${{ inputs.build-backend }}" + echo "" + echo "=== detect-changes outputs ===" + echo "needs.detect-changes.outputs.backend_needs_rebuild: ${{ needs.detect-changes.outputs.backend_needs_rebuild }}" + echo "needs.detect-changes.outcome: ${{ needs.detect-changes.outcome }}" + echo "" + echo "=== aggregate-downloads Debug ===" + echo "steps.read.outputs.result: ${{ steps.read.outputs.result }}" + echo "steps.read.outcome: ${{ steps.read.outcome }}" + echo "" + echo "=== All read outputs ===" + echo "${{ toJSON(steps.read.outputs) }}" + echo "" + echo "=== download-backend outcomes ===" + echo "needs.download-backend.outcome: ${{ needs.download-backend.outcome }}" + outputs: + outcome: ${{ steps.read.outcome != '' && steps.read.outcome || 'skipped' }} + any_needs_rebuild: ${{ steps.check-rebuild.outputs.any_needs_rebuild != '' && steps.check-rebuild.outputs.any_needs_rebuild || steps.set-defaults.outputs.any_needs_rebuild || 'false' }} + result: ${{ steps.read.outputs.result != '' && steps.read.outputs.result || steps.set-defaults.outputs.result || '{"downloaded":{"linux":false,"windows":false,"macos-intel":false,"macos-arm64":false}}' }} + + # Build Go backends on native platforms + # only if backend changed or download-backend failed + build-backend: + name: Build Backend - ${{ matrix.platform }} + needs: [detect-changes, download-backend, aggregate-downloads] + # Always is needed to execute this job even if backend download failed + # By default it would be skipped because dependent job failed + # Build if explicitly requested for this platform + # Also build if backend changed or download-backend failed + if: | + always() && + ( + inputs.build-backend == true || + needs.detect-changes.outputs.backend_needs_rebuild == 'true' || + needs.aggregate-downloads.outputs.outcome == 'success' && + needs.aggregate-downloads.outputs.any_needs_rebuild == 'true' + ) runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -88,124 +249,253 @@ jobs: include: - os: ubuntu-latest platform: linux - binary: backend-linux-amd64 - goarch: amd64 + binary_name: backend-linux-amd64 + - os: windows-latest platform: windows - binary: backend-windows-amd64.exe - goarch: amd64 + binary_name: backend-windows-amd64.exe + - os: macos-latest platform: macos-intel - binary: backend-darwin-amd64 + binary_name: backend-darwin-amd64 goarch: amd64 + - os: macos-latest platform: macos-arm64 - binary: backend-darwin-arm64 + binary_name: backend-darwin-arm64 goarch: arm64 + steps: - # OPTIMIZATION: Try to download existing artifact first - # Only runs if NO changes were detected and NO rebuild was forced - - name: Try Download Cache - if: needs.detect-changes.outputs.backend != 'true' - id: download - uses: dawidd6/action-download-artifact@v3 - continue-on-error: true - with: - workflow: build.yaml - branch: production - workflow_conclusion: success - name: backend-${{ matrix.platform }} - path: backend/cmd + - name: Debug + run: | + echo "=== aggregate-downloads Debug ===" + echo "needs.aggregate-downloads.outputs.result: ${{ needs.aggregate-downloads.outputs.result }}" + echo "needs.aggregate-downloads.outputs.result.downloaded: ${{ fromJSON(needs.aggregate-downloads.outputs.result).downloaded }}" + echo "needs.aggregate-downloads.outputs.result.downloaded.platform: ${{ fromJSON(needs.aggregate-downloads.outputs.result).downloaded[matrix.platform] }}" + + - name: Check if artifact exists for this platform + id: check-artifact + shell: bash + run: | + ARTIFACT_EXISTS="${{ fromJSON(needs.aggregate-downloads.outputs.result).downloaded[matrix.platform] }}" + BUILD_EXPLICITLY="${{ inputs.build-backend == true }}" + CHANGES_DETECTED="${{ needs.detect-changes.outputs.backend_needs_rebuild == 'true' }}" + + if [ "$ARTIFACT_EXISTS" != "true" ] || [ "$BUILD_EXPLICITLY" = "true" ] || [ "$CHANGES_DETECTED" = "true" ]; then + echo "needs_build=true" >> $GITHUB_OUTPUT + echo "Building for ${{ matrix.platform }}" + else + echo "needs_build=false" >> $GITHUB_OUTPUT + echo "Artifact exists for ${{ matrix.platform }}, skipping build" + fi - # BUILD: Only runs if download failed OR changes detected - - uses: actions/checkout@v4 - if: steps.download.outcome != 'success' + - name: Exit if no build needed + if: steps.check-artifact.outputs.needs_build != 'true' + shell: bash + run: | + echo "Skipping build - artifact already exists" - - uses: actions/setup-go@v5 - if: steps.download.outcome != 'success' + - name: Checkout repository + if: steps.check-artifact.outputs.needs_build == 'true' + uses: actions/checkout@v4 + + - name: Setup Go + if: steps.check-artifact.outputs.needs_build == 'true' + uses: actions/setup-go@v4 with: - go-version: "1.23" + go-version: "1.21" - - name: Install Linux Deps - if: runner.os == 'Linux' && steps.download.outcome != 'success' + - name: Install Linux dependencies + if: runner.os == 'Linux' && steps.check-artifact.outputs.needs_build == 'true' run: sudo apt-get update && sudo apt-get install -y libpcap-dev gcc - - name: Install macOS Deps - if: runner.os == 'macOS' && steps.download.outcome != 'success' + - name: Install macOS dependencies + if: runner.os == 'macOS' && steps.check-artifact.outputs.needs_build == 'true' run: brew install libpcap - - name: Build Binary - if: steps.download.outcome != 'success' + - name: Build backend (Linux) + if: runner.os == 'Linux' && steps.check-artifact.outputs.needs_build == 'true' working-directory: backend/cmd - shell: bash run: | - go build -o ${{ matrix.binary }} . - env: - CGO_ENABLED: 1 - GOARCH: ${{ matrix.goarch }} + CGO_ENABLED=1 go build -o ${{ matrix.binary_name }} . - # UPLOAD: Always runs to ensure artifact availability for release - - uses: actions/upload-artifact@v4 + - name: Build backend (Windows) + if: runner.os == 'Windows' && steps.check-artifact.outputs.needs_build == 'true' + working-directory: backend/cmd + run: | + $env:CGO_ENABLED="1" + go build -o ${{ matrix.binary_name }} . + shell: pwsh + + - name: Build backend (macOS) + if: runner.os == 'macOS' && steps.check-artifact.outputs.needs_build == 'true' + working-directory: backend/cmd + run: | + CGO_ENABLED=1 GOARCH=${{ matrix.goarch }} go build -o ${{ matrix.binary_name }} . + + - name: Upload backend binary + if: steps.check-artifact.outputs.needs_build == 'true' + uses: actions/upload-artifact@v4 with: name: backend-${{ matrix.platform }} - path: backend/cmd/${{ matrix.binary }} + path: backend/cmd/${{ matrix.binary_name }} retention-days: 30 - # ------------------------------------------------------------------ - # 3. BUILD FRONTEND (MATRIX) - # Builds Testing View and Competition View using pnpm & turbo - # ------------------------------------------------------------------ - build-frontend: - name: Build ${{ matrix.view }} + # Download control-station from previous build if no changes + download-control-station: + name: Download Control Station needs: detect-changes + # The condition checks if control station changed or common front changed + # It is important to use != 'true' and not == 'false' because if they are no inputs (executed on push or pull request events), + # values for inputs will be undefined which gives false for any condition + # The same thing applies for detect changes outputs, because we skip this job on dispatch and call events + # Thus, it's outputs are undefined + if: | + needs.detect-changes.outputs.control-station_needs_rebuild != 'true' && + needs.detect-changes.outputs.common-front_needs_rebuild != 'true' && + inputs.build-control-station != 'true' runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - view: [testing-view, - # competition-view] # TODO: Uncomment when competition view is ready - ] + # Continue on error is necessary because download-control-station job can fail if control station artifact is not found + # and it is not necessary to fail the build in this case + continue-on-error: true + outputs: + downloaded: ${{ steps.download.outcome == 'success' }} steps: - # OPTIMIZATION: Try to download existing artifact first - # Only runs if NO changes were detected and NO rebuild was forced - - name: Try Download Cache - if: needs.detect-changes.outputs[matrix.view] != 'true' + - name: Download control-station from latest build id: download uses: dawidd6/action-download-artifact@v3 - continue-on-error: true with: workflow: build.yaml branch: production - workflow_conclusion: success - name: ${{ matrix.view }} - path: frontend/${{ matrix.view }}/dist + workflow_conclusion: "completed" + name: control-station + path: control-station-artifacts + if_no_artifact_found: fail - # BUILD: Only runs if download failed OR changes detected - - uses: actions/checkout@v4 - if: steps.download.outcome != 'success' + - name: Re-upload control-station artifact + if: steps.download.outcome == 'success' + uses: actions/upload-artifact@v4 + with: + name: control-station + path: control-station-artifacts/** + retention-days: 30 - - uses: pnpm/action-setup@v4 - if: steps.download.outcome != 'success' + # Build control-station (if control-station or common-front changed) + build-control-station: + name: Build Control Station + needs: [detect-changes, download-control-station] + # Always is needed to execute this job even if control station download failed + # By default it would be skipped because dependent job failed + # The condition checks if control station changed or common front changed or download control station failed + if: always() && + (needs.detect-changes.outputs.control-station_needs_rebuild == 'true' || + needs.detect-changes.outputs.common-front_needs_rebuild == 'true' || + needs.download-control-station.outputs.downloaded != 'true' || + inputs.build-control-station == 'true') + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Build common-front + working-directory: common-front + run: | + npm ci + npm run build + + - name: Build control-station + working-directory: control-station + run: | + npm ci + npm run build + + - name: Upload control-station artifact + uses: actions/upload-artifact@v4 + with: + name: control-station + path: control-station/static/** + retention-days: 30 + + # Download ethernet-view from previous build if no changes + download-ethernet-view: + name: Download Ethernet View + needs: detect-changes + # It is important to use != 'true' and not == 'false' because if they are no inputs (executed on push or pull request events), + # values for inputs will be undefined which gives false for any condition + # The same thing applies for detect changes outputs, because we skip this job on dispatch and call events + # Thus, it's outputs are undefined + if: | + needs.detect-changes.outputs.ethernet-view_needs_rebuild != 'true' && + needs.detect-changes.outputs.common-front_needs_rebuild != 'true' && + inputs.build-ethernet-view != 'true' + runs-on: ubuntu-latest + # Continue on error is necessary because download-ethernet-view job can fail if ethernet view artifact is not found + # and it is not necessary to fail the build in this case + continue-on-error: true + outputs: + downloaded: ${{ steps.download.outcome == 'success' }} + steps: + - name: Download ethernet-view from latest build + id: download + uses: dawidd6/action-download-artifact@v3 with: - version: 10.26.0 + workflow: build.yaml + branch: production + workflow_conclusion: "completed" + name: ethernet-view + path: ethernet-view-artifacts + if_no_artifact_found: fail - - uses: actions/setup-node@v4 - if: steps.download.outcome != 'success' + - name: Re-upload ethernet-view artifact + if: steps.download.outcome == 'success' + uses: actions/upload-artifact@v4 with: - node-version: 20 - cache: "pnpm" + name: ethernet-view + path: ethernet-view-artifacts/** + retention-days: 30 - - name: Install Dependencies - if: steps.download.outcome != 'success' - run: pnpm install --frozen-lockfile + # Build ethernet-view (if ethernet-view or common-front changed) + build-ethernet-view: + name: Build Ethernet View + needs: [detect-changes, download-ethernet-view] + # Always is needed to execute this job even if ethernet view download failed + # By default it would be skipped because dependent job failed + # The condition checks if ethernet view changed or common front changed or download ethernet view failed + if: always() && + (needs.detect-changes.outputs.ethernet-view_needs_rebuild == 'true' || + needs.detect-changes.outputs.common-front_needs_rebuild == 'true' || + needs.download-ethernet-view.outputs.downloaded != 'true' || + inputs.build-ethernet-view == 'true') + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" - - name: Build with Turbo - if: steps.download.outcome != 'success' - run: pnpm turbo build --filter=${{ matrix.view }} + - name: Build common-front + working-directory: common-front + run: | + npm ci + npm run build + + - name: Build ethernet-view + working-directory: ethernet-view + run: | + npm ci + npm run build - # UPLOAD: Always runs to ensure artifact availability for release - - uses: actions/upload-artifact@v4 + - name: Upload ethernet-view artifact + uses: actions/upload-artifact@v4 with: - name: ${{ matrix.view }} - path: frontend/${{ matrix.view }}/dist/** + name: ethernet-view + path: ethernet-view/static/** retention-days: 30 diff --git a/.github/workflows/frontend-tests.yaml b/.github/workflows/frontend-tests.yaml deleted file mode 100644 index 149d955df..000000000 --- a/.github/workflows/frontend-tests.yaml +++ /dev/null @@ -1,45 +0,0 @@ -name: Frontend Tests - -on: - pull_request: - branches: - - main - - develop - - production - - "frontend/**" - - "testing-view/**" - - "competition-view/**" - paths: - - "frontend/**" - - "pnpm-lock.yaml" - - ".github/workflows/frontend-tests.yaml" - - push: - branches: - - "frontend/**" - - "testing-view/**" - - "competition-view/**" - paths: - - "frontend/**" - - "pnpm-lock.yaml" - - ".github/workflows/frontend-tests.yaml" - -jobs: - test: - name: Run Frontend Tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "pnpm" - - - name: Install dependencies - run: pnpm install --frozen-lockfile --filter=testing-view --filter=ui --filter=core - - - name: Run tests - run: pnpm test --filter="./frontend/**" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index abcc3c225..09ced9df5 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -67,22 +67,17 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - with: - version: 10.26.0 - - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: "20" - cache: "pnpm" # Update package.json with release version - name: Update version in package.json working-directory: electron-app shell: bash run: | - pnpm version ${{ needs.determine-version.outputs.version }} --no-git-tag-version + npm version ${{ needs.determine-version.outputs.version }} --no-git-tag-version echo "Updated version to:" cat package.json | grep version @@ -128,24 +123,23 @@ jobs: path: electron-app/binaries # Download frontend builds from latest build - # TODO: Uncomment when competition view is ready - # - name: Download competition-view - # uses: dawidd6/action-download-artifact@v3 - # with: - # workflow: build.yaml - # branch: production - # workflow_conclusion: success - # name: competition-view - # path: electron-app/renderer/competition-view - - - name: Download testing-view + - name: Download control-station + uses: dawidd6/action-download-artifact@v3 + with: + workflow: build.yaml + branch: production + workflow_conclusion: success + name: control-station + path: electron-app/renderer/control-station + + - name: Download ethernet-view uses: dawidd6/action-download-artifact@v3 with: workflow: build.yaml branch: production workflow_conclusion: success - name: testing-view - path: electron-app/renderer/testing-view + name: ethernet-view + path: electron-app/renderer/ethernet-view - name: Set executable permissions (Unix) if: runner.os != 'Windows' @@ -153,11 +147,11 @@ jobs: - name: Install Electron dependencies working-directory: electron-app - run: pnpm install + run: npm ci - name: Build Electron distribution working-directory: electron-app - run: pnpm run dist + run: npm run dist env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} CSC_IDENTITY_AUTO_DISCOVERY: false @@ -184,8 +178,6 @@ jobs: electron-app/dist/*.deb electron-app/dist/*.dmg electron-app/dist/*.zip - electron-app/dist/*.yml - electron-app/dist/*.blockmap !electron-app/dist/*-unpacked !electron-app/dist/mac !electron-app/dist/win-unpacked diff --git a/.gitignore b/.gitignore index 1d914a987..7af5c4844 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,22 @@ -# Monorepo / IDE -node_modules/ -.turbo/ -.vscode/ +build +profiles +backend/cmd/cmd +packet-sender/packet_sender +.ropeproject + +# MacOS Files .DS_Store -*.code-workspace -# Environment -.env +# Code Editor +.idea/ +.vscode/* +!.vscode/settings.json + +# Claude +CLAUDE* +.claude + +*.exe -# Global binaries -*.exe \ No newline at end of file +# VS Code Workspace +*.code-workspace \ No newline at end of file diff --git a/.starship.toml b/.starship.toml new file mode 100644 index 000000000..74b0b59b7 --- /dev/null +++ b/.starship.toml @@ -0,0 +1,90 @@ +# Hyperloop H10 Starship Prompt Configuration + +format = """ +[┌─](bold white) \ +$username\ +$hostname\ +$directory\ +$git_branch\ +$git_status\ +$golang\ +$nodejs\ +$python\ +$env_var\ +$custom\ +$cmd_duration\ +$line_break\ +[└─](bold white) $character""" + +[directory] +truncation_length = 3 +truncate_to_repo = true +style = "bold cyan" +read_only = " 🔒" + +[character] +success_symbol = "[🚄❯](bold green)" +error_symbol = "[🚄❯](bold red)" +vicmd_symbol = "[🚄❮](bold green)" + +[git_branch] +symbol = " " +style = "bold purple" +format = "on [$symbol$branch]($style) " + +[git_status] +style = "bold red" +format = '([\[$all_status$ahead_behind\]]($style) )' +conflicted = "⚔️ " +ahead = "⇡${count}" +behind = "⇣${count}" +diverged = "⇕⇡${ahead_count}⇣${behind_count}" +untracked = "?${count}" +stashed = "📦${count}" +modified = "!${count}" +staged = "+${count}" +renamed = "»${count}" +deleted = "✘${count}" + +[golang] +symbol = " " +style = "bold blue" +format = "via [$symbol($version )]($style)" + +[nodejs] +symbol = " " +style = "bold green" +format = "via [$symbol($version )]($style)" + +[python] +symbol = " " +style = "bold yellow" +format = "via [$symbol($version )]($style)" + +[cmd_duration] +min_time = 3_000 +format = "took [$duration]($style) " +style = "bold yellow" + +[env_var.NIX_SHELL] +symbol = "❄️ " +style = "bold blue" +format = "[$symbol]($style)" + +[custom.hyperloop] +command = "echo 🚄" +when = """ test "$REPO_ROOT" != "" """ +format = "[$output]($style) " +style = "bold" + +[hostname] +ssh_only = false +format = "on [$hostname](bold red) " +disabled = false + +[username] +style_user = "white bold" +style_root = "red bold" +format = "[$user]($style) " +disabled = false +show_always = false \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 9ef398bb3..dff16ee72 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,5 @@ { "editor.formatOnSave": true, - "editor.codeActionsOnSave": { - "source.organizeImports": "explicit" - }, // JSON files "[json]": { @@ -42,13 +39,5 @@ "go.lintFlags": ["-checks=all"], // Which checks to run // Prettier settings - "prettier.useEditorConfig": false, - - // Rust settings - "[rust]": { - "editor.defaultFormatter": "rust-lang.rust-analyzer", - "editor.codeActionsOnSave": { - "source.fixAll.rust-analyzer": "explicit" - } - } + "prettier.useEditorConfig": false } diff --git a/DOCUMENTATION_REORGANIZATION.md b/DOCUMENTATION_REORGANIZATION.md new file mode 100644 index 000000000..0bbbab6b0 --- /dev/null +++ b/DOCUMENTATION_REORGANIZATION.md @@ -0,0 +1,124 @@ +# Documentation Reorganization Summary + +This document outlines the reorganization of project documentation from scattered files to a centralized `docs/` folder structure. + +## 📁 New Documentation Structure + +``` +docs/ +├── README.md # Main documentation index +├── architecture/ +│ └── README.md # System architecture overview +├── development/ +│ ├── DEVELOPMENT.md # Development setup guide +│ ├── CROSS_PLATFORM_DEV_SUMMARY.md # Cross-platform scripts documentation +│ └── scripts.md # Scripts reference guide +├── guides/ +│ └── getting-started.md # New user getting started guide +└── troubleshooting/ + └── BLCU_FIX_SUMMARY.md # BLCU repair documentation +``` + +## 📋 File Migrations + +### Moved Files +| Original Location | New Location | Status | +|-------------------|--------------|--------| +| `DEVELOPMENT.md` | `docs/development/DEVELOPMENT.md` | ✅ Moved | +| `CROSS_PLATFORM_DEV_SUMMARY.md` | `docs/development/CROSS_PLATFORM_DEV_SUMMARY.md` | ✅ Moved | +| `scripts/README.md` | `docs/development/scripts.md` | ✅ Moved | +| `backend/BLCU_FIX_SUMMARY.md` | `docs/troubleshooting/BLCU_FIX_SUMMARY.md` | ✅ Moved | + +### New Files Created +| File | Purpose | +|------|---------| +| `docs/README.md` | Main documentation index with navigation | +| `docs/architecture/README.md` | System architecture overview | +| `docs/guides/getting-started.md` | Comprehensive new user guide | +| `scripts/README.md` | Quick reference pointing to full docs | + +### Updated Files +| File | Changes | +|------|---------| +| `README.md` | Added documentation section with quick links | +| `docs/development/scripts.md` | Updated paths for new location | + +## 🎯 Benefits of New Structure + +### 1. **Improved Organization** +- Clear categorization by purpose (development, architecture, guides, troubleshooting) +- Logical hierarchy that scales as documentation grows +- Centralized location for all project documentation + +### 2. **Better Discoverability** +- Single entry point through `docs/README.md` +- Clear navigation between related documents +- Quick links in main README for common tasks + +### 3. **Enhanced User Experience** +- Dedicated getting started guide for new users +- Platform-specific guidance clearly organized +- Troubleshooting docs easily accessible + +### 4. **Maintainability** +- Related documentation grouped together +- Easier to update and maintain consistency +- Clear ownership and responsibility areas + +## 🚀 How to Use the New Structure + +### For New Users +1. Start with [`docs/guides/getting-started.md`](docs/guides/getting-started.md) +2. Follow platform-specific setup in [`docs/development/DEVELOPMENT.md`](docs/development/DEVELOPMENT.md) +3. Refer to troubleshooting docs if needed + +### For Developers +1. Check [`docs/development/`](docs/development/) for all development-related docs +2. Use [`docs/architecture/`](docs/architecture/) to understand system design +3. Reference [`docs/development/scripts.md`](docs/development/scripts.md) for tooling + +### For Contributors +1. Review existing documentation structure before adding new docs +2. Place new documentation in appropriate category folders +3. Update main index (`docs/README.md`) when adding major new sections + +## 📝 Documentation Guidelines + +### Placement Rules +- **Development docs** → `docs/development/` +- **Architecture docs** → `docs/architecture/` +- **User guides** → `docs/guides/` +- **Troubleshooting** → `docs/troubleshooting/` +- **Component-specific** → Keep in respective component directories + +### Linking Guidelines +- Use relative paths for internal documentation links +- Update `docs/README.md` index when adding major new documents +- Cross-reference related documentation where helpful + +### File Naming +- Use lowercase with hyphens: `getting-started.md` +- Use descriptive names that indicate content purpose +- Keep README.md files for directory overviews + +## 🔗 Key Entry Points + +### Primary Documentation +- **[docs/README.md](docs/README.md)** - Main documentation hub +- **[README.md](README.md)** - Project overview with quick start + +### Quick Access +- **New Users**: [Getting Started Guide](docs/guides/getting-started.md) +- **Developers**: [Development Setup](docs/development/DEVELOPMENT.md) +- **Troubleshooting**: [Common Issues](docs/troubleshooting/BLCU_FIX_SUMMARY.md) + +## 🎉 Migration Complete + +The documentation reorganization provides: +- ✅ Better organization and navigation +- ✅ Improved new user experience +- ✅ Clearer separation of concerns +- ✅ Scalable structure for future growth +- ✅ Maintained backward compatibility through redirect notes + +All existing functionality remains accessible while providing a much better documentation experience for users, developers, and contributors. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..928f9e100 --- /dev/null +++ b/Makefile @@ -0,0 +1,158 @@ +# Hyperloop H10 Control Station Makefile + +.PHONY: all install clean build-backend build-common-front build-control-station build-ethernet-view +.PHONY: backend common-front control-station ethernet-view +.PHONY: dev-backend dev-control-station dev-ethernet-view +.PHONY: test test-backend test-frontend +.PHONY: ethernet-view-tmux control-station-tmux + +# Colors for output +GREEN := \033[0;32m +YELLOW := \033[0;33m +BLUE := \033[0;34m +RED := \033[0;31m +NC := \033[0m # No Color + +# Build directories +BACKEND_DIR := backend +COMMON_FRONT_DIR := common-front +CONTROL_STATION_DIR := control-station +ETHERNET_VIEW_DIR := ethernet-view + +# Output binary +BACKEND_BIN := $(BACKEND_DIR)/cmd/backend + +# Default target +all: install build + +# Install all dependencies +install: install-backend install-frontend + @echo "$(GREEN)✓ All dependencies installed$(NC)" + +install-backend: + @echo "$(BLUE)Installing backend dependencies...$(NC)" + @cd $(BACKEND_DIR) && go mod download + @echo "$(GREEN)✓ Backend dependencies installed$(NC)" + +install-frontend: + @echo "$(BLUE)Installing frontend dependencies...$(NC)" + @cd $(COMMON_FRONT_DIR) && npm install + @cd $(CONTROL_STATION_DIR) && npm install + @cd $(ETHERNET_VIEW_DIR) && npm install + @echo "$(GREEN)✓ Frontend dependencies installed$(NC)" + +# Build all components +build: build-backend build-frontend + @echo "$(GREEN)✓ All components built successfully$(NC)" + +build-frontend: build-common-front build-control-station build-ethernet-view + +# Individual build targets +backend build-backend: + @echo "$(BLUE)Building backend...$(NC)" + @cd $(BACKEND_DIR)/cmd && go build -o backend + @echo "$(GREEN)✓ Backend built: $(BACKEND_BIN)$(NC)" + +common-front build-common-front: + @echo "$(BLUE)Building common-front...$(NC)" + @cd $(COMMON_FRONT_DIR) && npm run build + @echo "$(GREEN)✓ Common-front built$(NC)" + +control-station build-control-station: build-common-front + @echo "$(BLUE)Building control-station...$(NC)" + @cd $(CONTROL_STATION_DIR) && npm run build + @echo "$(GREEN)✓ Control-station built$(NC)" + +ethernet-view build-ethernet-view: build-common-front + @echo "$(BLUE)Building ethernet-view...$(NC)" + @cd $(ETHERNET_VIEW_DIR) && npm run build + @echo "$(GREEN)✓ Ethernet-view built$(NC)" + +# Development servers (individual) +dev-backend: + @echo "$(YELLOW)Starting backend development server...$(NC)" + @cd $(BACKEND_DIR)/cmd && ./backend + +dev-control-station: + @echo "$(YELLOW)Starting control-station development server...$(NC)" + @cd $(CONTROL_STATION_DIR) && npm run dev + +dev-ethernet-view: + @echo "$(YELLOW)Starting ethernet-view development server...$(NC)" + @cd $(ETHERNET_VIEW_DIR) && npm run dev + +# Testing +test: test-backend test-frontend + +test-backend: + @echo "$(BLUE)Running backend tests...$(NC)" + @cd $(BACKEND_DIR) && go test -v -timeout 30s ./... + +test-frontend: + @echo "$(BLUE)Running frontend tests...$(NC)" + @cd $(ETHERNET_VIEW_DIR) && npm test || true + @echo "$(YELLOW)Note: Only ethernet-view has tests configured$(NC)" + +# Clean build artifacts +clean: + @echo "$(YELLOW)Cleaning build artifacts...$(NC)" + @rm -f $(BACKEND_BIN) + @rm -rf $(COMMON_FRONT_DIR)/dist + @rm -rf $(CONTROL_STATION_DIR)/dist + @rm -rf $(ETHERNET_VIEW_DIR)/dist + @echo "$(GREEN)✓ Clean complete$(NC)" + +# Combined tmux sessions +ethernet-view-tmux: build-backend + @echo "$(BLUE)Starting backend + ethernet-view in tmux...$(NC)" + @tmux new-session -d -s ethernet-view-session -n main + @tmux send-keys -t ethernet-view-session:main "cd $(BACKEND_DIR)/cmd && ./backend" C-m + @tmux split-window -t ethernet-view-session:main -h + @tmux send-keys -t ethernet-view-session:main.1 "cd $(ETHERNET_VIEW_DIR) && npm run dev" C-m + @tmux select-pane -t ethernet-view-session:main.0 + @tmux attach-session -t ethernet-view-session + +control-station-tmux: build-backend + @echo "$(BLUE)Starting backend + control-station in tmux...$(NC)" + @tmux new-session -d -s control-station-session -n main + @tmux send-keys -t control-station-session:main "cd $(BACKEND_DIR)/cmd && ./backend" C-m + @tmux split-window -t control-station-session:main -h + @tmux send-keys -t control-station-session:main.1 "cd $(CONTROL_STATION_DIR) && npm run dev" C-m + @tmux select-pane -t control-station-session:main.0 + @tmux attach-session -t control-station-session + +# Help target +help: + @echo "Hyperloop H10 Control Station - Build System" + @echo "===========================================" + @echo "" + @echo "$(YELLOW)Installation:$(NC)" + @echo " make install - Install all dependencies" + @echo " make install-backend - Install backend dependencies only" + @echo " make install-frontend - Install frontend dependencies only" + @echo "" + @echo "$(YELLOW)Building:$(NC)" + @echo " make all - Install deps and build everything" + @echo " make build - Build all components" + @echo " make backend - Build backend only" + @echo " make common-front - Build common frontend library" + @echo " make control-station - Build control station" + @echo " make ethernet-view - Build ethernet view" + @echo "" + @echo "$(YELLOW)Development:$(NC)" + @echo " make dev-backend - Run backend dev server" + @echo " make dev-control-station - Run control station dev server" + @echo " make dev-ethernet-view - Run ethernet view dev server" + @echo "" + @echo "$(YELLOW)Combined Sessions:$(NC)" + @echo " make ethernet-view-tmux - Run backend + ethernet-view in tmux" + @echo " make control-station-tmux - Run backend + control-station in tmux" + @echo "" + @echo "$(YELLOW)Testing:$(NC)" + @echo " make test - Run all tests" + @echo " make test-backend - Run backend tests" + @echo " make test-frontend - Run frontend tests" + @echo "" + @echo "$(YELLOW)Maintenance:$(NC)" + @echo " make clean - Remove build artifacts" + @echo " make help - Show this help message" \ No newline at end of file diff --git a/README.md b/README.md index c7bff8ea9..2b01eaa1d 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,55 @@ -# Hyperloop Control Station H11 - ![Testing View](https://raw.githubusercontent.com/Hyperloop-UPV/webpage/5c1c827d82d380689856ee61af43da30da22e0fc/src/assets/backgrounds/testing-view.png) +# Software - Control Station -## Monorepo usage +[![CI](https://github.com/HyperloopUPV-H8/software/actions/workflows/build-backend.yaml/badge.svg)](https://github.com/HyperloopUPV-H8/software/actions/workflows/build-backend.yaml) +[![CI](https://github.com/HyperloopUPV-H8/software/actions/workflows/build-ethernet-view.yaml/badge.svg)](https://github.com/HyperloopUPV-H8/software/actions/workflows/build-ethernet-view.yaml) +[![CI](https://github.com/HyperloopUPV-H8/software/actions/workflows/build-control-station.yaml/badge.svg)](https://github.com/HyperloopUPV-H8/software/actions/workflows/build-control-station.yaml) -This project implements `pnpm` workspaces and `turbo` pack to manage the development lifecycle across multiple languages and frameworks. +Hyperloop UPV's Control Station is a unified software solution for real-time monitoring and commanding of the pod. It combines a back-end (Go) that ingests and interprets sensor data–defined via the JSON-based "ADJ" specifications–and a front-end (Typescript/React) that displays metrics, logs, and diagnostics to operators. With features like packet parsing, logging, and live dashboards, it acts as the central hub to safely interface the pod, making it easier for team members to oversee performance, detect faults, and send precise orders to the vehicle. -### Prerequisites +control_station_mock -Before starting, ensure you have the following installed: +## Quick Start -- **PNPM** (v10.26.0+) -- **Node.js** (v20+) -- **Go** (for the backend) -- **Rust/Cargo** (for the packet-sender) +### For Users ---- +Download the latest release, unzip it and run the executable compatible with your OS. -### Workspaces Overview +### For Developers -Our `pnpm-workspace.yaml` defines the following workspaces: +See our comprehensive [Documentation](./docs/README.md) or jump to [Getting Started](./docs/guides/getting-started.md). Quick start: -| Workspace | Language | Description | -| :----------------------------- | :------- | :--------------------------------------------- | -| `testing-view` | TS/React | Web interface for telemetry testing | -| `competition-view` | TS/React | UI for the competition | -| `backend` | Go | Data ingestion and pod communication server | -| `packet-sender` | Rust | Utility for simulating vehicle packets | -| `electron-app` | JS | The main Control Station desktop application | -| `@workspace/ui` | TS/React | Shared UI component library (frontend-kit) | -| `@workspace/core` | TS | Shared business logic and types (frontend-kit) | -| `@workspace/eslint-config` | ESLint | Common ESLint configuration (frontend-kit) | -| `@workspace/typescript-config` | TS | Common TypeScript configuration (frontend-kit) | +```bash +# Clone and setup +git clone https://github.com/HyperloopUPV-H8/software.git +cd software +./scripts/dev.sh setup ---- +# Run services +./scripts/dev.sh backend # Backend server +./scripts/dev.sh ethernet # Ethernet view +./scripts/dev.sh control # Control station +``` -### Terminal Commands +## Configuration -These commands should be executed from the root directory (`/software`). +When using the Control Station make sure that you have configured your IP as the one specified in the ADJ—usually `192.168.0.9`. Then make sure to configure the boards you'll be making use of in the `config.toml` (at the top of the file you'll be able to see the `vehicle/boards` option, just add or remove the boards as needed following the format specified in the ADJ. -> **Note:** If you prefer to run scripts from a specific `package.json`, you can `cd` into the folder and execute them with `pnpm` without filtering. +To change the ADJ branch from `main`, change the option `adj/branch` at the end of the `config.toml` with the name of the branch you want to use or leave it blank if you'll be making use of a custom ADJ. -#### Global Development Scripts +## Documentation -- `pnpm dev` – Runs both frontends, the backend (with `dev-config.toml`), and the packet-sender in a single terminal window. -- `pnpm dev:main` – Runs frontends and the backend using the standard `config.toml`. +📚 **[Complete Documentation](./docs/README.md)** - All guides and references -#### Turbo Filtering +### Quick Links +- 🚀 **[Getting Started](./docs/guides/getting-started.md)** - New user guide +- 🛠️ **[Development Setup](./docs/development/DEVELOPMENT.md)** - Developer environment setup +- 🏗️ **[Architecture](./docs/architecture/README.md)** - System design overview +- 🔧 **[Troubleshooting](./docs/troubleshooting/BLCU_FIX_SUMMARY.md)** - Common issues and fixes -All Turbo scripts support filtering to target specific workspaces: +## Contributing -- `pnpm dev --filter testing-view` – Runs the `dev` script specifically for the Testing View. +See [CONTRIBUTING.md](./CONTRIBUTING.md) for ways to contribute to the Control Station. -> **! Important:** You must refer to a workspace by the `name` field defined in its local `package.json`. +### About -#### Lifecycle Scripts - -- `pnpm build` – Compiles every package in the monorepo (Go binaries, Rust crates, and Vite apps). -- `pnpm test` – Runs all test suites across the repo (Vitest, Go tests, and Cargo tests). -- `pnpm lint` – Runs ESLint across all TypeScript packages. -- `pnpm preview` – Previews the production Vite builds for the frontend applications. -- `pnpm ui:add ` - To add shadcn/ui components - - > Note: don't forget to also include it in frontend-kit/ui/src/components/shadcn/index.ts to be able to access it from @workspace/ui +HyperloopUPV is a student team based at Universitat Politècnica de València (Spain), which works every year to develop the transport of the future, the hyperloop. Check out [our website](https://hyperloopupv.com/#/) diff --git a/backend/.gitignore b/backend/.gitignore index 384729b1f..141acc284 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,22 +1,31 @@ log trace.json -/bin +# Windows build +*.exe +# Linux build +Backend-H8 +backend +!build +# MacOS build +cmd/cmd +cmd/logger/ -# Test coverage -*testfile +# EXCEL +*.xlsx + +static + +.vscode + +downloads + +audience_static + +cmd/adj/ +cmd/config.toml -# Build artifacts -/bin/ -backend -trace.json # Test data -*.out c.out - -# Logs -cmd/logger/ -cmd/cmd -cmd/adj -/logger/ \ No newline at end of file +cover.out diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev new file mode 100644 index 000000000..7c86595f5 --- /dev/null +++ b/backend/Dockerfile.dev @@ -0,0 +1,14 @@ +FROM golang:1.21-alpine + +RUN apk add --no-cache gcc musl-dev libpcap-dev pkgconfig + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +EXPOSE 8080 + +CMD ["sh", "-c", "cd cmd && go run ."] \ No newline at end of file diff --git a/backend/build/Dockerfile b/backend/build/Dockerfile new file mode 100644 index 000000000..564b8bcc4 --- /dev/null +++ b/backend/build/Dockerfile @@ -0,0 +1,16 @@ +FROM golang:1.21-alpine + +RUN apk update +RUN apk add --no-cache libpcap-dev musl-dev gcc + +WORKDIR /backend + +COPY . /backend + +RUN go mod tidy + +ENV CGO_ENABLED=1 +ENV GOARCH=amd64 +ENV GOOS=linux + +CMD go build -C "cmd" -ldflags '-linkmode external -extldflags "-static"' -o "../build/backend" \ No newline at end of file diff --git a/backend/build/config/config.toml b/backend/build/config/config.toml new file mode 100644 index 000000000..efa6e85af --- /dev/null +++ b/backend/build/config/config.toml @@ -0,0 +1,96 @@ + +[server.local] +address = "127.0.0.1:4000" +static = "./static" + +[server.local.endpoints] +pod_data = "/podDataStructure" +order_data = "/orderStructures" +programable_boards = "/uploadableBoards" +connections = "/backend" +file_server = "/" + +[vehicle] +boards = ["VCU"] + +[vehicle.network] +tcp_client_tag = "TCP_CLIENT" +tcp_server_tag = "TCP_SERVER" +udp_tag = "UDP" +mtu = 1500 +interface = "lo" +keepalive = "1s" +timeout = "1s" + +[vehicle.messages] +info_id_key = "info" +fault_id_key = "fault" +warning_id_key = "warning" +error_id_key = "error" +blcu_ack_id_key = "blcu_ack" +add_state_orders_id_key = "add_state_orders" +remove_state_orders_id_key = "remove_state_orders" + +[excel.download] +id = "1BEwASubu0el9oQA6PSwVKaNU-Q6gbJ40JR6kgqguKYE" +name = "ade.xlsx" +path = "." + +[excel.parse] +global_sheet_prefix = "GLOBAL " +board_sheet_prefix = "BOARD " +table_prefix = "[TABLE] " +[excel.parse.global] +address_table = "addresses" +backend_key = "Backend" +blcu_address_key = "BLCU" +units_table = "units" +ports_table = "ports" +board_ids_table = "board_ids" +message_ids_table = "message_ids" + +[logger_handler] +topics = { enable = "logger/enable" } +base_path = "log" +flush_interval = "5s" + +[packet_logger] +file_name = "packets" +flush_interval = "5s" + +[value_logger] +folder_name = "values" +flush_interval = "5s" + +[order_logger] +file_name = "orders" +flush_interval = "5s" + +[protection_logger] +file_name = "protections" +flush_interval = "5s" + +[orders] +send_topic = "order/send" + +[messages] +update_topic = "message/update" + +[data_transfer] +fps = 20 +topics = { update = "podData/update" } + +[connections] +update_topic = "connection/update" + +[blcu] +download_path = "downloads" + +[blcu.packets] +upload = { id = 700, field = "board" } +download = { id = 701, field = "board" } +ack = { name = "tftp_ack" } + +[blcu.topics] +upload = "blcu/upload" +download = "blcu/download" diff --git a/backend/build/config/ethernet-view.toml b/backend/build/config/ethernet-view.toml new file mode 100644 index 000000000..dc33487bb --- /dev/null +++ b/backend/build/config/ethernet-view.toml @@ -0,0 +1,101 @@ + +[server.local] +address = "127.0.0.1:4000" +static = "./static" + +[server.local.endpoints] +pod_data = "/podDataStructure" +order_data = "/orderStructures" +programable_boards = "/uploadableBoards" +connections = "/backend" +file_server = "/" + +[vehicle] +boards = ["VCU"] + +[vehicle.network] +tcp_client_tag = "TCP_CLIENT" +tcp_server_tag = "TCP_SERVER" +udp_tag = "UDP" +# sniffer = { mtu = 1500, interface = "lo" } +mtu = 1500 +interface = "lo" +# blcu_ack_id = "blcu_ack" +keep_alive_interval = "1s" +keep_alive_probes = 3 +timeout = "1s" + +[vehicle.messages] +info_id_key = "info" +fault_id_key = "fault" +warning_id_key = "warning" +error_id_key = "error" +blcu_ack_id_key = "blcu_ack" +add_state_orders_id_key = "add_state_orders" +remove_state_orders_id_key = "remove_state_orders" + +[excel.download] +#id = "1XE9V2PI0hwSdAC8P6MePnSLyzADqsdWCOlx_kct7dps" +id="1b_nOrWqjMLOSEFIV9dMUObnJ15J7ypmF-KVJ4qztAtw" +#id = "1BEwASubu0el9oQA6PSwVKaNU-Q6gbJ40JR6kgqguKYE" +name = "ade.xlsx" +path = "." + +[excel.parse] +global_sheet_prefix = "GLOBAL " +board_sheet_prefix = "BOARD " +table_prefix = "[TABLE] " +[excel.parse.global] +address_table = "addresses" +backend_key = "Backend" +blcu_address_key = "BLCU" +units_table = "units" +ports_table = "ports" +board_ids_table = "board_ids" +message_ids_table = "message_ids" + +[logger_handler] +topics = { enable = "logger/enable" } +base_path = "log" +flush_interval = "5s" + +[packet_logger] +file_name = "packets" +flush_interval = "5s" + +[value_logger] +folder_name = "values" +flush_interval = "5s" + +[order_logger] +file_name = "orders" +flush_interval = "5s" + +[protection_logger] +file_name = "protections" +flush_interval = "5s" + +[orders] +send_topic = "order/send" + +[messages] +update_topic = "message/update" + +[data_transfer] +fps = 20 +topics = { update = "podData/update" } + +[connections] +update_topic = "connection/update" + +[blcu] +download_path = "downloads" + +[blcu.packets] +upload = { id = 700, field = "write_board" } +download = { id = 701, field = "read_board" } +ack = { name = "tftp_ack" } + +[blcu.topics] +upload = "blcu/upload" +download = "blcu/download" diff --git a/backend/cmd/config.toml b/backend/cmd/config.toml index 90f742c3b..52915d2e6 100644 --- a/backend/cmd/config.toml +++ b/backend/cmd/config.toml @@ -19,6 +19,10 @@ boards = ["BCU", "BMSL", "HVSCU", "HVSCU-Cabinet", "LCU", "PCU", "VCU", "BLCU"] [adj] branch = "main" # Leave blank when using ADJ as a submodule (like this: "") +# Network Configuration +[network] +manual = false # Manual network device selection + # Transport Configuration [transport] propagate_fault = false @@ -33,6 +37,12 @@ max_retries = 0 # Maximum retries before cycling (0 = infinite retr connection_timeout_ms = 1000 # Connection timeout in milliseconds keep_alive_ms = 1000 # Keep-alive interval in milliseconds +# BLCU (Boot Loader Control Unit) Configuration +[blcu] +ip = "127.0.0.1" # TFTP server IP address +download_order_id = 0 # Packet ID for download orders (0 = use default) +upload_order_id = 0 # Packet ID for upload orders (0 = use default) + # TFTP Configuration [tftp] block_size = 131072 # TFTP block size in bytes (128kB) diff --git a/backend/cmd/dev-config.toml b/backend/cmd/dev-config.toml index 0ea168ec3..633306e9f 100644 --- a/backend/cmd/dev-config.toml +++ b/backend/cmd/dev-config.toml @@ -20,6 +20,9 @@ boards = ["HVSCU", "HVSCU-Cabinet", "PCU", "LCU", "BCU", "BMSL"] [adj] branch = "software" # Leave blank when using ADJ as a submodule (like this: "") +# Network Configuration +[network] +manual = false # Manual network device selection # Transport Configuration [transport] @@ -66,9 +69,19 @@ timeout_ms = 5000 # Timeout for TFTP operations in milliseconds backoff_factor = 2 # Backoff factor for retries enable_progress = true # Enable progress updates during transfers - +# BLCU Configuration +[blcu] +ip = "10.10.10.5" # BLCU IP address +download_order_id = 0 # Order ID for download operations (0 = use default) +upload_order_id = 0 # Order ID for upload operations (0 = use default) # Logging Configuration [logging] +level = "debug" # Logging level (trace, debug, info, warn, error, fatal) +console = true # Enable console output +file = "backend.log" # Log file path (empty to disable file logging) +max_size_mb = 100 # Maximum log file size in MB +max_backups = 3 # Number of backup files to keep +max_age_days = 7 # Maximum age of log files in days time_unit = "us" # Time unit for log timestamps (ns, us, ms, s) logging_path = "." \ No newline at end of file diff --git a/backend/cmd/main.go b/backend/cmd/main.go index addc5c879..167e6721c 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -1,16 +1,17 @@ package main import ( + "flag" _ "net/http/pprof" "os" "os/signal" - adj_module "github.com/HyperloopUPV-H8/h9-backend/internal/adj" + adj_module "github.com/HyperloopUPV-H8/h9-backend/pkg/adj" "github.com/HyperloopUPV-H8/h9-backend/internal/config" - "github.com/HyperloopUPV-H8/h9-backend/internal/flags" "github.com/HyperloopUPV-H8/h9-backend/internal/pod_data" "github.com/HyperloopUPV-H8/h9-backend/internal/update_factory" vehicle_models "github.com/HyperloopUPV-H8/h9-backend/internal/vehicle/models" + "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport" "github.com/HyperloopUPV-H8/h9-backend/pkg/websocket" trace "github.com/rs/zerolog/log" @@ -23,17 +24,30 @@ const ( TcpServer = "TCP_SERVER" UDP = "UDP" SNTP = "SNTP" + BlcuAck = "blcu_ack" AddStateOrder = "add_state_order" RemoveStateOrder = "remove_state_order" ) +var configFile = flag.String("config", "config.toml", "path to configuration file") +var traceLevel = flag.String("trace", "info", "set the trace level (\"fatal\", \"error\", \"warn\", \"info\", \"debug\", \"trace\")") +var traceFile = flag.String("log", "", "set the trace log file") +var cpuprofile = flag.String("cpuprofile", "", "write cpu profile to file") +var enableSNTP = flag.Bool("sntp", false, "enables a simple SNTP server on port 123") +var networkDevice = flag.Int("dev", -1, "index of the network device to use, overrides device prompt") +var blockprofile = flag.Int("blockprofile", 0, "number of block profiles to include") +var playbackFile = flag.String("playback", "", "") +var versionFlag = flag.Bool("version", false, "Show the backend version") + +type SubloggersMap map[abstraction.LoggerName]abstraction.Logger + func main() { // Parse command line flags - flags.Init() + flag.Parse() handleVersionFlag() // Configure trace - traceFile := initTrace(flags.TraceLevel, flags.TraceFile) + traceFile := initTrace(*traceLevel, *traceFile) if traceFile != nil { defer traceFile.Close() } @@ -43,7 +57,7 @@ func main() { defer cleanup() // <--- config ---> - config, err := config.GetConfig(flags.ConfigFile) + config, err := config.GetConfig(*configFile) if err != nil { trace.Fatal().Err(err).Msg("error unmarshaling toml file") } @@ -61,7 +75,7 @@ func main() { } // <--- vehicle orders ---> - vehicleOrders, err := vehicle_models.NewVehicleOrders(podData.Boards) + vehicleOrders, err := vehicle_models.NewVehicleOrders(podData.Boards, adj.Info.Addresses[BLCU]) if err != nil { trace.Fatal().Err(err).Msg("creating vehicleOrders") } @@ -111,7 +125,7 @@ func main() { ) // <--- http server ---> - configureHTTPServer( + configureHttpServer( adj, podData, vehicleOrders, @@ -125,14 +139,6 @@ func main() { os.Exit(1) } - // Start logger - if flags.EnableLooger { - err = loggerHandler.Start() - if err != nil { - trace.Fatal().Err(err).Msg("starting logger") - } - } - // Open browser tabs openBrowserTabs(config) diff --git a/backend/cmd/orchestrator.go b/backend/cmd/orchestrator.go index 180b26c8d..d9bd3f5fd 100644 --- a/backend/cmd/orchestrator.go +++ b/backend/cmd/orchestrator.go @@ -7,9 +7,8 @@ import ( "runtime/pprof" "strings" - adj_module "github.com/HyperloopUPV-H8/h9-backend/internal/adj" + adj_module "github.com/HyperloopUPV-H8/h9-backend/pkg/adj" "github.com/HyperloopUPV-H8/h9-backend/internal/config" - "github.com/HyperloopUPV-H8/h9-backend/internal/flags" "github.com/HyperloopUPV-H8/h9-backend/internal/pod_data" "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" "github.com/HyperloopUPV-H8/h9-backend/pkg/logger" @@ -22,7 +21,7 @@ import ( // Handle version flag func handleVersionFlag() { - if flags.Version { + if *versionFlag { versionFile := "VERSION.txt" versionData, err := os.ReadFile(versionFile) if err == nil { @@ -44,8 +43,8 @@ func setupRuntimeCPU() func() { cleanup := func() {} runtime.GOMAXPROCS(runtime.NumCPU()) - if flags.CPUProfile != "" { - f, err := os.Create(flags.CPUProfile) + if *cpuprofile != "" { + f, err := os.Create(*cpuprofile) if err != nil { f.Close() trace.Fatal().Stack().Err(err).Msg("could not set up CPU profiling") @@ -58,7 +57,7 @@ func setupRuntimeCPU() func() { f.Close() } } - runtime.SetBlockProfileRate(flags.BlockProfile) + runtime.SetBlockProfileRate(*blockprofile) return cleanup } @@ -135,9 +134,9 @@ func createLookupTables( createBoardToPackets(podData) } -func setUpLogger(config config.Config) (*logger.Logger, abstraction.SubloggersMap) { +func setUpLogger(config config.Config) (*logger.Logger, SubloggersMap) { - var subloggers = abstraction.SubloggersMap{ + var subloggers = SubloggersMap{ data_logger.Name: data_logger.NewLogger(), order_logger.Name: order_logger.NewLogger(), } diff --git a/backend/cmd/setup_transport.go b/backend/cmd/setup_transport.go index c86f9270e..b045f7be4 100644 --- a/backend/cmd/setup_transport.go +++ b/backend/cmd/setup_transport.go @@ -7,15 +7,17 @@ import ( "net" "time" - adj_module "github.com/HyperloopUPV-H8/h9-backend/internal/adj" + adj_module "github.com/HyperloopUPV-H8/h9-backend/pkg/adj" "github.com/HyperloopUPV-H8/h9-backend/internal/common" "github.com/HyperloopUPV-H8/h9-backend/internal/config" "github.com/HyperloopUPV-H8/h9-backend/internal/pod_data" "github.com/HyperloopUPV-H8/h9-backend/internal/utils" "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" + "github.com/HyperloopUPV-H8/h9-backend/pkg/boards" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/network/tcp" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/network/udp" + blcu_packet "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/packet/blcu" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/packet/data" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/packet/order" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/packet/protection" @@ -43,6 +45,11 @@ func configureTransport( transp.SetTargetIp(adj.Info.Addresses[board.Name], abstraction.TransportTarget(board.Name)) } + // If BLCU is configured set BLCU packet ID mappings + if common.Contains(config.Vehicle.Boards, "BLCU") { + configureBLCUTransport(adj, transp, config) + } + // Start handling TCP CLIENT connections configureTCPClientTransport(adj, podData, transp, config) @@ -54,6 +61,36 @@ func configureTransport( } +// configureBLCUTransport sets the packet IDs and target IP for the BLCU board. +// It prefers values from config, falls back to ADJ and finally to a loopback default. +func configureBLCUTransport(adj adj_module.ADJ, + transp *transport.Transport, + config config.Config) { + // Use configurable packet IDs or defaults + downloadOrderID := config.Blcu.DownloadOrderId + uploadOrderID := config.Blcu.UploadOrderId + if downloadOrderID == 0 { + downloadOrderID = boards.DefaultBlcuDownloadOrderId + } + if uploadOrderID == 0 { + uploadOrderID = boards.DefaultBlcuUploadOrderId + } + + transp.SetIdTarget(abstraction.PacketId(downloadOrderID), abstraction.TransportTarget("BLCU")) + transp.SetIdTarget(abstraction.PacketId(uploadOrderID), abstraction.TransportTarget("BLCU")) + + // Use BLCU address from config, ADJ, or default + blcuIP := config.Blcu.IP + if blcuIP == "" { + if adjBlcuIP, exists := adj.Info.Addresses[BLCU]; exists { + blcuIP = adjBlcuIP + } else { + blcuIP = "127.0.0.1" + } + } + transp.SetTargetIp(blcuIP, abstraction.TransportTarget("BLCU")) +} + func configureTCPClientTransport( adj adj_module.ADJ, podData pod_data.PodData, @@ -210,6 +247,9 @@ func getTransportDecEnc(info adj_module.Info, podData pod_data.PodData) (*presen encoder.SetPacketEncoder(id, dataEncoder) } + // Register BLCU ack decoder + decoder.SetPacketDecoder(abstraction.PacketId(info.MessageIds[BlcuAck]), blcu_packet.NewDecoder()) + // TODO Solve this foking mess, I have tried... stateOrdersDecoder := order.NewDecoder(binary.LittleEndian) stateOrdersDecoder.SetActionId(abstraction.PacketId(info.MessageIds[AddStateOrder]), stateOrdersDecoder.DecodeAdd) diff --git a/backend/cmd/setup_vehicle.go b/backend/cmd/setup_vehicle.go index b8a7b62e0..81aa8de05 100644 --- a/backend/cmd/setup_vehicle.go +++ b/backend/cmd/setup_vehicle.go @@ -7,18 +7,19 @@ import ( "os" "time" - "github.com/HyperloopUPV-H8/h9-backend/internal/flags" vehicle_models "github.com/HyperloopUPV-H8/h9-backend/internal/vehicle/models" h "github.com/HyperloopUPV-H8/h9-backend/pkg/http" "github.com/HyperloopUPV-H8/h9-backend/pkg/websocket" - adj_module "github.com/HyperloopUPV-H8/h9-backend/internal/adj" + adj_module "github.com/HyperloopUPV-H8/h9-backend/pkg/adj" "github.com/HyperloopUPV-H8/h9-backend/internal/common" "github.com/HyperloopUPV-H8/h9-backend/internal/config" "github.com/HyperloopUPV-H8/h9-backend/internal/pod_data" "github.com/HyperloopUPV-H8/h9-backend/internal/update_factory" "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" + "github.com/HyperloopUPV-H8/h9-backend/pkg/boards" "github.com/HyperloopUPV-H8/h9-backend/pkg/broker" + blcu_topics "github.com/HyperloopUPV-H8/h9-backend/pkg/broker/topics/blcu" connection_topic "github.com/HyperloopUPV-H8/h9-backend/pkg/broker/topics/connection" data_topic "github.com/HyperloopUPV-H8/h9-backend/pkg/broker/topics/data" logger_topic "github.com/HyperloopUPV-H8/h9-backend/pkg/broker/topics/logger" @@ -32,7 +33,7 @@ import ( trace "github.com/rs/zerolog/log" ) -func configureBroker(subloggers abstraction.SubloggersMap, loggerHandler *logger.Logger, idToBoard map[abstraction.PacketId]string, connections chan *websocket.Client) (*broker.Broker, func()) { +func configureBroker(subloggers SubloggersMap, loggerHandler *logger.Logger, idToBoard map[abstraction.PacketId]string, connections chan *websocket.Client) (*broker.Broker, func()) { broker := broker.New(trace.Logger) @@ -40,7 +41,7 @@ func configureBroker(subloggers abstraction.SubloggersMap, loggerHandler *logger cleanup := func() { dataTopic.Stop() } connectionTopic := connection_topic.NewUpdateTopic() orderTopic := order_topic.NewSendTopic() - loggerTopic := logger_topic.NewEnableTopic(trace.Logger) + loggerTopic := logger_topic.NewEnableTopic() loggerTopic.SetDataLogger(subloggers[data_logger.Name].(*data_logger.Logger)) loggerHandler.SetOnStart(func() { if err := loggerTopic.NotifyStarted(); err != nil { @@ -62,17 +63,8 @@ func configureBroker(subloggers abstraction.SubloggersMap, loggerHandler *logger broker.AddTopic(message_topic.UpdateName, messageTopic) pool := websocket.NewPool(connections, trace.Logger) - pool.SetOnDisconnect(func(count int) { - if count == 0 { - trace.Info().Msg("no clients connected, stopping logger") - loggerHandler.Stop() - if err := loggerTopic.NotifyStopped(); err != nil { - trace.Error().Err(err).Msg("failed to notify logger stopped") - } - } - }) - broker.SetPool(pool) + blcu_topics.RegisterTopics(broker, pool) return broker, cleanup } @@ -98,13 +90,52 @@ func configureVehicle( vehicle.SetIdToBoardName(idToBoard) vehicle.SetTransport(transp) + // Register BLCU board for handling bootloader operations + if blcuIP, exists := adj.Info.Addresses[BLCU]; exists { + blcuId, idExists := adj.Info.BoardIds["BLCU"] + if !idExists { + return fmt.Errorf("BLCU IP found in ADJ but board ID missing") + } else { + // Get configurable order IDs or use defaults + downloadOrderId := config.Blcu.DownloadOrderId + uploadOrderId := config.Blcu.UploadOrderId + if downloadOrderId == 0 { + downloadOrderId = boards.DefaultBlcuDownloadOrderId + } + if uploadOrderId == 0 { + uploadOrderId = boards.DefaultBlcuUploadOrderId + } + + tftpConfig := boards.TFTPConfig{ + BlockSize: config.TFTP.BlockSize, + Retries: config.TFTP.Retries, + TimeoutMs: config.TFTP.TimeoutMs, + BackoffFactor: config.TFTP.BackoffFactor, + EnableProgress: config.TFTP.EnableProgress, + } + blcuBoard := boards.NewWithConfig(blcuIP, tftpConfig, abstraction.BoardId(blcuId), downloadOrderId, uploadOrderId) + vehicle.AddBoard(blcuBoard) + vehicle.SetBlcuId(abstraction.BoardId(blcuId)) + + trace. + Info(). + Str("ip", blcuIP). + Int("id", int(blcuId)). + Uint16("download_order_id", downloadOrderId). + Uint16("upload_order_id", uploadOrderId). + Msg("BLCU board registered") + } + } else { + trace.Warn().Msg("BLCU not found in ADJ configuration - bootloader operations unavailable") + } + return nil } func configureSNTP(adj adj_module.ADJ) bool { - if flags.EnableSNTP { + if *enableSNTP { sntpAddr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", adj.Info.Addresses[BACKEND], adj.Info.Ports[SNTP])) if err != nil { fmt.Fprintf(os.Stderr, "error resolving sntp address: %v\n", err) @@ -129,7 +160,7 @@ func configureSNTP(adj adj_module.ADJ) bool { return false } -func configureHTTPServer( +func configureHttpServer( adj adj_module.ADJ, podData pod_data.PodData, vehicleOrders vehicle_models.VehicleOrders, diff --git a/backend/go.mod b/backend/go.mod index 2571668d5..b8dcb8938 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -7,11 +7,13 @@ require ( github.com/google/gopacket v1.1.19 github.com/google/uuid v1.3.0 github.com/gorilla/websocket v1.5.0 + github.com/hashicorp/go-version v1.7.0 github.com/jmaralo/sntp v0.0.0-20240116111937-45a0a3419272 github.com/pelletier/go-toml/v2 v2.0.7 github.com/pin/tftp/v3 v3.0.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/rs/zerolog v1.29.0 + github.com/stretchr/testify v1.9.0 golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 ) @@ -21,6 +23,7 @@ require ( github.com/ProtonMail/go-crypto v1.0.0 // indirect github.com/cloudflare/circl v1.3.7 // indirect github.com/cyphar/filepath-securejoin v0.2.4 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.5.0 // indirect @@ -31,6 +34,7 @@ require ( github.com/mattn/go-isatty v0.0.17 // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/skeema/knownhosts v1.2.2 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect @@ -39,8 +43,10 @@ require ( golang.org/x/sys v0.31.0 // indirect golang.org/x/tools v0.13.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) require ( + github.com/fatih/color v1.15.0 golang.org/x/net v0.38.0 // indirect ) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 3be243de2..930a68114 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -5,7 +5,6 @@ import ( "os" "strings" - "github.com/HyperloopUPV-H8/h9-backend/internal/flags" "github.com/pelletier/go-toml/v2" trace "github.com/rs/zerolog/log" ) @@ -23,13 +22,8 @@ func GetConfig(path string) (Config, error) { var config Config - decode := toml.NewDecoder(reader) - - // Set whether to disallow unknown fields based on the flag - if !flags.ConfigAllowUnknown { - decode.DisallowUnknownFields() - } - decodeErr := decode.Decode(&config) + // TODO: add strict mode (DisallowUnkownFields) + decodeErr := toml.NewDecoder(reader).Decode(&config) if decodeErr != nil { diff --git a/backend/internal/config/config_types.go b/backend/internal/config/config_types.go index 1c0a3a559..f018f8af3 100644 --- a/backend/internal/config/config_types.go +++ b/backend/internal/config/config_types.go @@ -14,6 +14,10 @@ type Adj struct { Branch string `toml:"branch"` } +type Network struct { + Manual bool `toml:"manual"` +} + type Transport struct { PropagateFault bool `toml:"propagate_fault"` } @@ -26,6 +30,12 @@ type TFTP struct { EnableProgress bool `toml:"enable_progress"` } +type Blcu struct { + IP string `toml:"ip"` + DownloadOrderId uint16 `toml:"download_order_id"` + UploadOrderId uint16 `toml:"upload_order_id"` +} + type TCP struct { BackoffMinMs int `toml:"backoff_min_ms"` BackoffMaxMs int `toml:"backoff_max_ms"` @@ -45,8 +55,10 @@ type Config struct { Vehicle vehicle.Config Server server.Config Adj Adj + Network Network Transport Transport TFTP TFTP TCP TCP + Blcu Blcu Logging Logging } diff --git a/backend/internal/flags/flags.go b/backend/internal/flags/flags.go deleted file mode 100644 index 1ed66bd67..000000000 --- a/backend/internal/flags/flags.go +++ /dev/null @@ -1,43 +0,0 @@ -// Package flags defines command-line flags for the backend application. -package flags - -import "flag" - -var ( - - // ConfigFile specifies the path to the configuration file. - ConfigFile string - // ConfigAllowUnknown enables non-strict mode for configuration parsing. - ConfigAllowUnknown bool - - // TraceLevel sets the logging level for tracing. - TraceLevel string - // TraceFile specifies the file to write trace logs to. - TraceFile string - - // CPUProfile specifies the file to write CPU profiling data to. - CPUProfile string - // EnableSNTP enables a simple SNTP server on port 123. - EnableSNTP bool - // BlockProfile sets the number of block profiles to include. - BlockProfile int - // Version shows the backend version when set. - Version bool - // EnableLooger enables logging (note: likely a typo for "Logger"). - EnableLooger bool -) - -// Init sets up the command-line flags with their default values and descriptions. -func Init() { - flag.StringVar(&ConfigFile, "config", "config.toml", "path to configuration file") - flag.BoolVar(&ConfigAllowUnknown, "config-allow-unknown", false, "allow unknown fields in configuration file") - flag.StringVar(&TraceLevel, "trace", "info", "set the trace level (\"fatal\", \"error\", \"warn\", \"info\", \"debug\", \"trace\")") - flag.StringVar(&TraceFile, "log", "", "set the trace log file") - flag.StringVar(&CPUProfile, "cpuprofile", "", "write cpu profile to file") - flag.BoolVar(&EnableSNTP, "sntp", false, "enables a simple SNTP server on port 123") - flag.IntVar(&BlockProfile, "blockprofile", 0, "number of block profiles to include") - flag.BoolVar(&Version, "version", false, "Show the backend version") - flag.BoolVar(&EnableLooger, "L", false, "enable logging") - - flag.Parse() -} diff --git a/backend/internal/pod_data/measurement.go b/backend/internal/pod_data/measurement.go index 446b88611..a5980d357 100644 --- a/backend/internal/pod_data/measurement.go +++ b/backend/internal/pod_data/measurement.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "github.com/HyperloopUPV-H8/h9-backend/internal/adj" + "github.com/HyperloopUPV-H8/h9-backend/pkg/adj" "github.com/HyperloopUPV-H8/h9-backend/internal/common" "github.com/HyperloopUPV-H8/h9-backend/internal/utils" ) diff --git a/backend/internal/pod_data/pod_data.go b/backend/internal/pod_data/pod_data.go index 7cafa1411..89169aff4 100644 --- a/backend/internal/pod_data/pod_data.go +++ b/backend/internal/pod_data/pod_data.go @@ -3,7 +3,7 @@ package pod_data import ( "github.com/HyperloopUPV-H8/h9-backend/internal/utils" - "github.com/HyperloopUPV-H8/h9-backend/internal/adj" + "github.com/HyperloopUPV-H8/h9-backend/pkg/adj" "github.com/HyperloopUPV-H8/h9-backend/internal/common" ) diff --git a/backend/internal/utils/units.go b/backend/internal/utils/units.go index cd835434a..32d9386bd 100644 --- a/backend/internal/utils/units.go +++ b/backend/internal/utils/units.go @@ -1,4 +1,3 @@ -// Package utils provides utility functions for handling unit conversions and operations. package utils import ( @@ -14,7 +13,6 @@ const ( Separator = "#" ) -// operationExp matches an operator followed by a decimal number var operationExp = regexp.MustCompile(fmt.Sprintf(`([+\-\/*]{1})(%s)`, DecimalRegex)) type Units struct { @@ -22,7 +20,6 @@ type Units struct { Operations Operations } -// ParseUnits given a units literal string and a map of global units, returns the corresponding Units func ParseUnits(literal string, globalUnits map[string]Operations) (Units, error) { // TODO: puede fallar si no tiene op y no estan en global o si las op que tiene estan mal if literal == "" { return Units{ @@ -62,26 +59,19 @@ func ParseUnits(literal string, globalUnits map[string]Operations) (Units, error }, nil } -// Operations is a list of operations to be applied in order type Operations []Operation -// NewOperations given an operations literal string, returns the corresponding Operations func NewOperations(literal string) (Operations, error) { - // Empty operations if literal == "" { return make(Operations, 0), nil } - // Find all operations in the literal - // match structure [[full_match, operator, operand], ...] matches := operationExp.FindAllStringSubmatch(literal, -1) - // If no matches found, return an error if matches == nil { return nil, fmt.Errorf("incorrect operations: %s", literal) } - // create a operation for each match operations := make([]Operation, 0) for _, match := range matches { operation := getOperation(match[1], match[2]) @@ -90,7 +80,6 @@ func NewOperations(literal string) (Operations, error) { return operations, nil } -// given an operator and operand string, returns the corresponding Operation func getOperation(operator string, operand string) Operation { numOperand, err := strconv.ParseFloat(operand, 64) if err != nil { @@ -102,7 +91,6 @@ func getOperation(operator string, operand string) Operation { } } -// Convert applies each operation in order to the value given func (operations Operations) Convert(value float64) float64 { result := value for _, op := range operations { @@ -111,7 +99,6 @@ func (operations Operations) Convert(value float64) float64 { return result } -// Revert reverts each operation in reverse order from the value given func (operations Operations) Revert(value float64) float64 { result := value for i := len(operations) - 1; i >= 0; i-- { @@ -120,13 +107,11 @@ func (operations Operations) Revert(value float64) float64 { return result } -// Operation representation of a single operation composed by a operator and an operand type Operation struct { - Operator string // "+", "-", "*", "/" - Operand float64 // number + Operator string + Operand float64 } -// applies the operation to the given value func (operation Operation) convert(value float64) float64 { switch operation.Operator { case "+": @@ -141,7 +126,6 @@ func (operation Operation) convert(value float64) float64 { return value } -// reverts the operation from the given value func (operation Operation) revert(value float64) float64 { switch operation.Operator { case "+": diff --git a/backend/internal/vehicle/models/order_data.go b/backend/internal/vehicle/models/order_data.go index e19ff59de..ac4bc044b 100644 --- a/backend/internal/vehicle/models/order_data.go +++ b/backend/internal/vehicle/models/order_data.go @@ -36,7 +36,7 @@ type StateOrderDescription struct { Enabled bool `json:"enabled"` } -func NewVehicleOrders(boards []pod_data.Board) (VehicleOrders, error) { +func NewVehicleOrders(boards []pod_data.Board, blcuName string) (VehicleOrders, error) { vehicleOrders := VehicleOrders{ Boards: make([]BoardOrders, 0), } diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 000000000..8f9553dbe --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,13 @@ +{ + "name": "hyperloop-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hyperloop-backend", + "version": "1.0.0", + "license": "ISC" + } + } +} diff --git a/backend/package.json b/backend/package.json index 942b86567..1d9f29dba 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,14 +1,12 @@ { - "name": "backend", + "name": "hyperloop-backend", "version": "1.0.0", - "private": true, - "author": "Hyperloop UPV Team", - "license": "MIT", + "main": "index.js", "scripts": { - "dev": "go run ./cmd --config ./cmd/dev-config.toml", - "dev:main": "go run ./cmd --config ./cmd/config.toml", - "build": "go build -o bin/backend ./cmd", - "build:ci": "go build", - "test": "go test ./..." - } + "start": "node index.js" + }, + "author": "", + "license": "ISC", + "keywords": [], + "description": "" } diff --git a/backend/pkg/abstraction/logger.go b/backend/pkg/abstraction/logger.go index a44ef1655..df9d77391 100644 --- a/backend/pkg/abstraction/logger.go +++ b/backend/pkg/abstraction/logger.go @@ -1,4 +1,3 @@ -// Package abstraction provides interfaces for logging functionality. package abstraction // LoggerName is the name of the logger that manages a piece of data @@ -24,6 +23,3 @@ type Logger interface { // PullRecord will retrieve a record from disk PullRecord(LoggerRequest) (LoggerRecord, error) } - -// SubloggersMap is a map of logger names to their respective loggers -type SubloggersMap map[LoggerName]Logger diff --git a/backend/internal/adj/adj.go b/backend/pkg/adj/adj.go similarity index 96% rename from backend/internal/adj/adj.go rename to backend/pkg/adj/adj.go index 8fcb5add6..41a740b5c 100644 --- a/backend/internal/adj/adj.go +++ b/backend/pkg/adj/adj.go @@ -10,7 +10,8 @@ import ( ) const ( - RepoURL = "https://github.com/Hyperloop-UPV/adj.git" // URL of the ADJ repository + RepoUrl = "https://github.com/HyperloopUPV-H8/adj.git" // URL of the ADJ repository + ) var RepoPath = getRepoPath() diff --git a/backend/internal/adj/boards.go b/backend/pkg/adj/boards.go similarity index 100% rename from backend/internal/adj/boards.go rename to backend/pkg/adj/boards.go diff --git a/backend/internal/adj/git.go b/backend/pkg/adj/git.go similarity index 73% rename from backend/internal/adj/git.go rename to backend/pkg/adj/git.go index e0700771f..95b48d20d 100644 --- a/backend/internal/adj/git.go +++ b/backend/pkg/adj/git.go @@ -9,12 +9,7 @@ import ( "github.com/go-git/go-git/v5/plumbing" ) -// updateRepo ensures that the local ADJ repository matches the specified remote branch. -// It first performs a test clone to verify remote accessibility (including internet -// connectivity). If the remote branch is accessible, the local repository is completely -// removed and replaced with a clean, shallow clone of that branch. -// If the remote is not accessible, the existing local repository is left untouched. - +// WARNING: Doing tricks on it func updateRepo(AdjBranch string) error { var err error @@ -23,7 +18,7 @@ func updateRepo(AdjBranch string) error { return nil } else { cloneOptions := &git.CloneOptions{ - URL: RepoURL, + URL: RepoUrl, ReferenceName: plumbing.NewBranchReferenceName(AdjBranch), SingleBranch: true, Depth: 1, @@ -50,7 +45,6 @@ func updateRepo(AdjBranch string) error { return err } - // After checking that the repo is accessible, clone or update (overwrite) the local ADJ repo if _, err = os.Stat(RepoPath); os.IsNotExist(err) { _, err = git.PlainClone(RepoPath, false, cloneOptions) if err != nil { diff --git a/backend/internal/adj/models.go b/backend/pkg/adj/models.go similarity index 100% rename from backend/internal/adj/models.go rename to backend/pkg/adj/models.go diff --git a/backend/pkg/boards/blcu.go b/backend/pkg/boards/blcu.go new file mode 100644 index 000000000..9c4534404 --- /dev/null +++ b/backend/pkg/boards/blcu.go @@ -0,0 +1,275 @@ +package boards + +import ( + "bytes" + "fmt" + "time" + + "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" + "github.com/HyperloopUPV-H8/h9-backend/pkg/transport" + "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/network/tftp" + dataPacket "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/packet/data" +) + +const ( + BlcuName = "BLCU" + + AckId = abstraction.BoardEvent("ACK") + DownloadEventId = abstraction.BoardEvent("DOWNLOAD") + UploadEventId = abstraction.BoardEvent("UPLOAD") + + // Default order IDs - can be overridden via config.toml + DefaultBlcuDownloadOrderId = 701 + DefaultBlcuUploadOrderId = 700 +) + +type TFTPConfig struct { + BlockSize int + Retries int + TimeoutMs int + BackoffFactor int + EnableProgress bool +} + +type BLCU struct { + api abstraction.BoardAPI + ackChan chan struct{} + ip string + tftpConfig TFTPConfig + id abstraction.BoardId + downloadOrderId uint16 + uploadOrderId uint16 +} + +// Deprecated: Use NewWithConfig with proper board ID and order IDs from configuration +func New(ip string) *BLCU { + return NewWithTFTPConfig(ip, TFTPConfig{ + BlockSize: 131072, // 128kB + Retries: 3, + TimeoutMs: 5000, + BackoffFactor: 2, + EnableProgress: true, + }, 0) // Board ID 0 indicates missing configuration +} + +// Deprecated: Use NewWithConfig for proper order ID configuration +func NewWithTFTPConfig(ip string, tftpConfig TFTPConfig, id abstraction.BoardId) *BLCU { + return &BLCU{ + ackChan: make(chan struct{}), + ip: ip, + tftpConfig: tftpConfig, + id: id, + downloadOrderId: DefaultBlcuDownloadOrderId, + uploadOrderId: DefaultBlcuUploadOrderId, + } +} + +func NewWithConfig(ip string, tftpConfig TFTPConfig, id abstraction.BoardId, downloadOrderId, uploadOrderId uint16) *BLCU { + return &BLCU{ + ackChan: make(chan struct{}), + ip: ip, + tftpConfig: tftpConfig, + id: id, + downloadOrderId: downloadOrderId, + uploadOrderId: uploadOrderId, + } +} +func (board *BLCU) Id() abstraction.BoardId { + return board.id +} + +func (boards *BLCU) Notify(boardNotification abstraction.BoardNotification) { + switch notification := boardNotification.(type) { + case *AckNotification: + boards.ackChan <- struct{}{} + + case *DownloadEvent: + err := boards.download(*notification) + if err != nil { + fmt.Println(ErrDownloadFailure{ + Timestamp: time.Now(), + Inner: err, + }.Error()) + } + case *UploadEvent: + err := boards.upload(*notification) + if err != nil { + fmt.Println(ErrUploadFailure{ + Timestamp: time.Now(), + Inner: err, + }.Error()) + } + default: + fmt.Println(ErrInvalidBoardEvent{ + Event: notification.Event(), + Timestamp: time.Now(), + }.Error()) + } +} + +func (boards *BLCU) SetAPI(api abstraction.BoardAPI) { + boards.api = api +} + +func (boards *BLCU) download(notification DownloadEvent) error { + // Notify the BLCU + ping := dataPacket.NewPacketWithValues( + abstraction.PacketId(boards.downloadOrderId), + map[dataPacket.ValueName]dataPacket.Value{ + BlcuName: dataPacket.NewEnumValue(dataPacket.EnumVariant(notification.Board)), + }, + map[dataPacket.ValueName]bool{ + BlcuName: true, + }) + + err := boards.api.SendMessage(transport.NewPacketMessage(ping)) + if err != nil { + return ErrSendMessageFailed{ + Timestamp: time.Now(), + Inner: err, + } + } + + // Wait for the ACK + <-boards.ackChan + + // TODO! Notify on progress + + client, err := tftp.NewClient(boards.ip, + tftp.WithBlockSize(boards.tftpConfig.BlockSize), + tftp.WithRetries(boards.tftpConfig.Retries), + tftp.WithTimeout(time.Duration(boards.tftpConfig.TimeoutMs)*time.Millisecond), + ) + if err != nil { + return ErrNewClientFailed{ + Addr: boards.ip, + Timestamp: time.Now(), + Inner: err, + } + } + + buffer := &bytes.Buffer{} + + _, err = client.ReadFile(BlcuName, tftp.BinaryMode, buffer) + if err != nil { + pushErr := boards.api.SendPush(abstraction.BrokerPush( + &DownloadFailure{ + Error: err, + }, + )) + if pushErr != nil { + return ErrSendMessageFailed{ + Timestamp: time.Now(), + Inner: pushErr, + } + } + + return ErrReadingFileFailed{ + Filename: string(notification.Event()), + Timestamp: time.Now(), + Inner: err, + } + } + + pushErr := boards.api.SendPush(abstraction.BrokerPush( + &DownloadSuccess{ + Data: buffer.Bytes(), + }, + )) + if pushErr != nil { + return ErrSendMessageFailed{ + Timestamp: time.Now(), + Inner: err, + } + } + + return nil +} + +func (boards *BLCU) upload(notification UploadEvent) error { + ping := dataPacket.NewPacketWithValues(abstraction.PacketId(boards.uploadOrderId), + map[dataPacket.ValueName]dataPacket.Value{ + BlcuName: dataPacket.NewEnumValue(dataPacket.EnumVariant(notification.Board)), + }, + map[dataPacket.ValueName]bool{ + BlcuName: true, + }) + + err := boards.api.SendMessage(transport.NewPacketMessage(ping)) + if err != nil { + return ErrSendMessageFailed{ + Timestamp: time.Now(), + Inner: err, + } + } + + <-boards.ackChan + + // TODO! Notify on progress + + client, err := tftp.NewClient(boards.ip, + tftp.WithBlockSize(boards.tftpConfig.BlockSize), + tftp.WithRetries(boards.tftpConfig.Retries), + tftp.WithTimeout(time.Duration(boards.tftpConfig.TimeoutMs)*time.Millisecond), + ) + if err != nil { + return ErrNewClientFailed{ + Addr: boards.ip, + Timestamp: time.Now(), + Inner: err, + } + } + + data := notification.Data + buffer := bytes.NewBuffer(data) + + read, err := client.WriteFile(BlcuName, tftp.BinaryMode, buffer) + if err != nil { + pushErr := boards.api.SendPush(abstraction.BrokerPush( + &UploadFailure{ + Error: err, + })) + if pushErr != nil { + return ErrSendMessageFailed{ + Timestamp: time.Now(), + Inner: pushErr, + } + } + + return ErrReadingFileFailed{ + Filename: string(notification.Event()), + Timestamp: time.Now(), + Inner: err, + } + } + + // Check if all bytes written + if int(read) != len(data) { + err = ErrNotAllBytesWritten{ + Timestamp: time.Now(), + } + + pushErr := boards.api.SendPush(abstraction.BrokerPush( + &UploadFailure{ + Error: err, + })) + if pushErr != nil { + return ErrSendMessageFailed{ + Timestamp: time.Now(), + Inner: pushErr, + } + } + + return err + } + + pushErr := boards.api.SendPush(abstraction.BrokerPush( + &UploadSuccess{})) + if pushErr != nil { + return ErrSendMessageFailed{ + Timestamp: time.Now(), + Inner: pushErr, + } + } + return nil +} diff --git a/backend/pkg/boards/blcu_integration_test.go b/backend/pkg/boards/blcu_integration_test.go new file mode 100644 index 000000000..6586c88c2 --- /dev/null +++ b/backend/pkg/boards/blcu_integration_test.go @@ -0,0 +1,284 @@ +package boards_test + +import ( + "encoding/json" + "testing" + "time" + + "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" + "github.com/HyperloopUPV-H8/h9-backend/pkg/boards" + "github.com/HyperloopUPV-H8/h9-backend/pkg/broker" + blcu_topic "github.com/HyperloopUPV-H8/h9-backend/pkg/broker/topics/blcu" + "github.com/HyperloopUPV-H8/h9-backend/pkg/vehicle" + "github.com/HyperloopUPV-H8/h9-backend/pkg/websocket" + "github.com/rs/zerolog" +) + +// MockTransport implements abstraction.Transport for testing +type MockTransport struct { + sentMessages []abstraction.TransportMessage +} + +func (m *MockTransport) SendMessage(msg abstraction.TransportMessage) error { + m.sentMessages = append(m.sentMessages, msg) + return nil +} + +func (m *MockTransport) HandleClient(config interface{}, target string) error { + return nil +} + +func (m *MockTransport) HandleServer(config interface{}, addr string) error { + return nil +} + +func (m *MockTransport) HandleSniffer(sniffer interface{}) error { + return nil +} + +func (m *MockTransport) SetAPI(api abstraction.TransportAPI) {} + +func (m *MockTransport) SetIdTarget(id abstraction.PacketId, target abstraction.TransportTarget) {} + +func (m *MockTransport) SetTargetIp(ip string, target abstraction.TransportTarget) {} + +func (m *MockTransport) SetpropagateFault(propagate bool) {} + +func (m *MockTransport) WithDecoder(decoder interface{}) abstraction.Transport { + return m +} + +func (m *MockTransport) WithEncoder(encoder interface{}) abstraction.Transport { + return m +} + +// MockLogger implements abstraction.Logger for testing +type MockLogger struct{} + +func (m *MockLogger) Start() error { + return nil +} + +func (m *MockLogger) Stop() error { + return nil +} + +func (m *MockLogger) PushRecord(record abstraction.LoggerRecord) error { + return nil +} + +func (m *MockLogger) PullRecord(request abstraction.LoggerRequest) (abstraction.LoggerRecord, error) { + return nil, nil +} + +// TestBLCUDownloadOrder tests the BLCU download order flow +func TestBLCUDownloadOrder(t *testing.T) { + // Setup + logger := zerolog.New(nil).Level(zerolog.Disabled) + + // Create vehicle + v := vehicle.New(logger) + + // Create and setup broker + b := broker.New(logger) + connections := make(chan *websocket.Client) + pool := websocket.NewPool(connections, logger) + b.SetPool(pool) + + // Register BLCU topics + blcu_topic.RegisterTopics(b, pool) + + // Set broker and transport + v.SetBroker(b) + mockTransport := &MockTransport{} + v.SetTransport(mockTransport) + mockLogger := &MockLogger{} + v.SetLogger(mockLogger) + + // Create BLCU board + blcuBoard := boards.New("192.168.0.10") // Example IP + + // This is the missing step - register the BLCU board with the vehicle + v.AddBoard(blcuBoard) + + // Note: In a real scenario, we would capture responses through the broker + + // Test download request + t.Run("Download Request", func(t *testing.T) { + downloadRequest := &blcu_topic.DownloadRequest{ + Board: "VCU", + } + + // Send download request through UserPush + err := v.UserPush(downloadRequest) + if err != nil { + t.Fatalf("UserPush failed: %v", err) + } + + // Simulate ACK from board + blcuBoard.Notify(boards.AckNotification{ + ID: boards.AckId, + }) + + // Check if the download order was sent to the board + if len(mockTransport.sentMessages) == 0 { + t.Fatal("No message sent to transport") + } + + // Verify the packet sent contains the correct order ID + // In a real test, we would decode the packet and verify its contents + }) +} + +// TestBLCUUploadOrder tests the BLCU upload order flow +func TestBLCUUploadOrder(t *testing.T) { + // Setup + logger := zerolog.New(nil).Level(zerolog.Disabled) + + // Create vehicle + v := vehicle.New(logger) + + // Create and setup broker + b := broker.New(logger) + connections := make(chan *websocket.Client) + pool := websocket.NewPool(connections, logger) + b.SetPool(pool) + + // Register BLCU topics + blcu_topic.RegisterTopics(b, pool) + + // Set broker and transport + v.SetBroker(b) + mockTransport := &MockTransport{} + v.SetTransport(mockTransport) + mockLogger := &MockLogger{} + v.SetLogger(mockLogger) + + // Create BLCU board + blcuBoard := boards.New("192.168.0.10") // Example IP + + // Register the BLCU board with the vehicle + v.AddBoard(blcuBoard) + + // Test upload request + t.Run("Upload Request", func(t *testing.T) { + // Using the internal request type that has Data field + uploadRequest := &blcu_topic.UploadRequestInternal{ + Board: "VCU", + Data: []byte("test firmware data"), + } + + // Send upload request through UserPush + err := v.UserPush(uploadRequest) + if err != nil { + t.Fatalf("UserPush failed: %v", err) + } + + // Simulate ACK from board + blcuBoard.Notify(boards.AckNotification{ + ID: boards.AckId, + }) + + // Check if the upload order was sent to the board + if len(mockTransport.sentMessages) == 0 { + t.Fatal("No message sent to transport") + } + }) +} + +// TestBLCUWebSocketFlow tests the complete WebSocket flow for BLCU orders +func TestBLCUWebSocketFlow(t *testing.T) { + // Setup + logger := zerolog.New(nil).Level(zerolog.Disabled) + + // Create vehicle + v := vehicle.New(logger) + + // Create and setup broker + b := broker.New(logger) + connections := make(chan *websocket.Client) + pool := websocket.NewPool(connections, logger) + b.SetPool(pool) + + // Register BLCU topics + blcu_topic.RegisterTopics(b, pool) + + // Set broker + v.SetBroker(b) + mockTransport := &MockTransport{} + v.SetTransport(mockTransport) + mockLogger := &MockLogger{} + v.SetLogger(mockLogger) + + // Create BLCU board + blcuBoard := boards.New("192.168.0.10") + v.AddBoard(blcuBoard) + + // Simulate WebSocket client message + t.Run("WebSocket Download Message", func(t *testing.T) { + // Get download topic handler from registered topics + downloadHandler := &blcu_topic.Download{} + downloadHandler.SetAPI(b) + downloadHandler.SetPool(pool) + + // Create WebSocket message + downloadReq := blcu_topic.DownloadRequest{ + Board: "VCU", + } + payload, _ := json.Marshal(downloadReq) + + wsMessage := &websocket.Message{ + Topic: blcu_topic.DownloadName, + Payload: payload, + } + + // Simulate client message + // Create a valid UUID for ClientId + clientUUID := [16]byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} + clientId := websocket.ClientId(clientUUID) + downloadHandler.ClientMessage(clientId, wsMessage) + + // Give some time for async operations + time.Sleep(100 * time.Millisecond) + + // Verify order was sent + if len(mockTransport.sentMessages) == 0 { + t.Error("No message sent to transport after WebSocket message") + } + }) +} + +// TestBLCURegistrationIssue demonstrates the issue when BLCU is not registered +func TestBLCURegistrationIssue(t *testing.T) { + // Setup WITHOUT registering BLCU board + logger := zerolog.New(nil).Level(zerolog.Disabled) + + v := vehicle.New(logger) + b := broker.New(logger) + connections := make(chan *websocket.Client) + pool := websocket.NewPool(connections, logger) + b.SetPool(pool) + blcu_topic.RegisterTopics(b, pool) + v.SetBroker(b) + + // Try to send download request without BLCU board registered + t.Run("Download Without Registration", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + // If no panic, check if the request was handled + // In the current implementation, this will fail silently + t.Log("Request handled without BLCU registration - this is the bug!") + } + }() + + downloadRequest := &blcu_topic.DownloadRequest{ + Board: "VCU", + } + + // This will fail because boards[boards.BlcuId] is nil + err := v.UserPush(downloadRequest) + if err == nil { + t.Log("UserPush succeeded but BLCU board notification will fail") + } + }) +} \ No newline at end of file diff --git a/backend/pkg/boards/blcu_simple_test.go b/backend/pkg/boards/blcu_simple_test.go new file mode 100644 index 000000000..7ff5633e8 --- /dev/null +++ b/backend/pkg/boards/blcu_simple_test.go @@ -0,0 +1,106 @@ +package boards_test + +import ( + "testing" + + "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" + "github.com/HyperloopUPV-H8/h9-backend/pkg/boards" + blcu_topic "github.com/HyperloopUPV-H8/h9-backend/pkg/broker/topics/blcu" + "github.com/HyperloopUPV-H8/h9-backend/pkg/vehicle" + "github.com/rs/zerolog" +) + +// TestBLCUBoardRegistration tests that BLCU board can be registered with different configurations +func TestBLCUBoardRegistration(t *testing.T) { + logger := zerolog.New(nil).Level(zerolog.Disabled) + v := vehicle.New(logger) + + // Test deprecated constructor (should use board ID 0) + blcuBoard := boards.New("192.168.0.10") + v.AddBoard(blcuBoard) + + // Verify board is registered with ID 0 (missing configuration) + if blcuBoard.Id() != 0 { + t.Errorf("Expected board ID 0 for deprecated constructor, got %d", blcuBoard.Id()) + } +} + +// TestBLCUWithCustomConfiguration tests BLCU with custom board ID and order IDs +func TestBLCUWithCustomConfiguration(t *testing.T) { + logger := zerolog.New(nil).Level(zerolog.Disabled) + v := vehicle.New(logger) + + // Test new constructor with custom configuration + tftpConfig := boards.TFTPConfig{ + BlockSize: 131072, + Retries: 3, + TimeoutMs: 5000, + BackoffFactor: 2, + EnableProgress: true, + } + + customBoardId := abstraction.BoardId(7) + customDownloadOrderId := uint16(801) + customUploadOrderId := uint16(802) + + blcuBoard := boards.NewWithConfig("192.168.0.10", tftpConfig, customBoardId, customDownloadOrderId, customUploadOrderId) + v.AddBoard(blcuBoard) + + // Verify board is registered with custom ID + if blcuBoard.Id() != customBoardId { + t.Errorf("Expected board ID %d, got %d", customBoardId, blcuBoard.Id()) + } +} + +// TestBLCUWithDefaultConfiguration tests BLCU with default order IDs +func TestBLCUWithDefaultConfiguration(t *testing.T) { + logger := zerolog.New(nil).Level(zerolog.Disabled) + v := vehicle.New(logger) + + // Test deprecated constructor (should use default order IDs) + tftpConfig := boards.TFTPConfig{ + BlockSize: 131072, + Retries: 3, + TimeoutMs: 5000, + BackoffFactor: 2, + EnableProgress: true, + } + + boardId := abstraction.BoardId(7) + blcuBoard := boards.NewWithTFTPConfig("192.168.0.10", tftpConfig, boardId) + v.AddBoard(blcuBoard) + + // Verify board is registered + if blcuBoard.Id() != boardId { + t.Errorf("Expected board ID %d, got %d", boardId, blcuBoard.Id()) + } +} + +// TestBLCURequestStructures tests the request structures +func TestBLCURequestStructures(t *testing.T) { + // Test download request + downloadReq := &blcu_topic.DownloadRequest{ + Board: "VCU", + } + if downloadReq.Topic() != "blcu/downloadRequest" { + t.Errorf("Expected topic 'blcu/downloadRequest', got '%s'", downloadReq.Topic()) + } + + // Test upload request + uploadReq := &blcu_topic.UploadRequest{ + Board: "VCU", + File: "dGVzdCBkYXRh", // base64 for "test data" + } + if uploadReq.Topic() != "blcu/uploadRequest" { + t.Errorf("Expected topic 'blcu/uploadRequest', got '%s'", uploadReq.Topic()) + } + + // Test internal upload request + uploadReqInternal := &blcu_topic.UploadRequestInternal{ + Board: "VCU", + Data: []byte("test data"), + } + if uploadReqInternal.Topic() != "blcu/uploadRequest" { + t.Errorf("Expected topic 'blcu/uploadRequest', got '%s'", uploadReqInternal.Topic()) + } +} \ No newline at end of file diff --git a/backend/pkg/boards/events.go b/backend/pkg/boards/events.go index 77a3cbef1..045195a83 100644 --- a/backend/pkg/boards/events.go +++ b/backend/pkg/boards/events.go @@ -18,6 +18,10 @@ type DownloadEvent struct { Board string } +func (download DownloadEvent) Topic() abstraction.BrokerTopic { + return "blcu/download" +} + func (download DownloadEvent) Event() abstraction.BoardEvent { return download.BoardEvent } @@ -29,6 +33,10 @@ type UploadEvent struct { Length int } +func (upload UploadEvent) Topic() abstraction.BrokerTopic { + return "blcu/upload" +} + func (upload UploadEvent) Event() abstraction.BoardEvent { return upload.BoardEvent } @@ -37,20 +45,40 @@ type BoardPush struct { Data int64 } +func (boardPush BoardPush) Topic() abstraction.BrokerTopic { + return "blcu/boardPush" +} + type DownloadSuccess struct { Data []byte } +func (downloadSuccess DownloadSuccess) Topic() abstraction.BrokerTopic { + return "blcu/download/success" +} + type UploadSuccess struct{} +func (uploadSuccess UploadSuccess) Topic() abstraction.BrokerTopic { + return "blcu/upload/success" +} + type DownloadFailure struct { Error error } +func (downloadFailure DownloadFailure) Topic() abstraction.BrokerTopic { + return "blcu/download/failure" +} + type UploadFailure struct { Error error } +func (uploadFailure UploadFailure) Topic() abstraction.BrokerTopic { + return "blcu/upload/failure" +} + type BoardMessage struct { ID abstraction.TransportEvent // UploadName } diff --git a/backend/pkg/broker/topics/blcu/blcu_test.go b/backend/pkg/broker/topics/blcu/blcu_test.go new file mode 100644 index 000000000..fd3136224 --- /dev/null +++ b/backend/pkg/broker/topics/blcu/blcu_test.go @@ -0,0 +1,204 @@ +package blcu_test + +import ( + "encoding/json" + "fmt" + "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" + "github.com/HyperloopUPV-H8/h9-backend/pkg/broker" + "github.com/HyperloopUPV-H8/h9-backend/pkg/broker/topics/blcu" + "github.com/HyperloopUPV-H8/h9-backend/pkg/broker/topics/tests_functions" + "github.com/HyperloopUPV-H8/h9-backend/pkg/websocket" + ws "github.com/gorilla/websocket" + "github.com/rs/zerolog" + "log" + "os" + "testing" + "time" +) + +var errorFlag bool + +type OutputNotMatchingError struct{} + +func (e *OutputNotMatchingError) Error() string { + return "Output does not match" +} + +type MockAPI struct{} + +func (api MockAPI) UserPush(push abstraction.BrokerPush) error { + switch push.(type) { + case blcu.DownloadRequest: + if push.(blcu.DownloadRequest).Board != "test" { + errorFlag = true + return &OutputNotMatchingError{} + } + errorFlag = false + log.Printf("Output matches") + return nil + case *blcu.UploadRequestInternal: + req := push.(*blcu.UploadRequestInternal) + if req.Board != "test" || string(req.Data) != "test" { + errorFlag = true + fmt.Printf("Expected board 'test' and data 'test', got board '%s' and data '%s'\n", req.Board, string(req.Data)) + return &OutputNotMatchingError{} + } + errorFlag = false + log.Printf("Output matches") + return nil + } + return nil +} + +func (api MockAPI) UserPull(request abstraction.BrokerRequest) (abstraction.BrokerResponse, error) { + return nil, nil +} + +func TestBLCUTopic_Download_Push(t *testing.T) { + logger := zerolog.New(os.Stdout).With().Timestamp().Logger() + clientChan := make(chan *websocket.Client) + u := tests_functions.StartServer(logger, "download") + + // Mock first client as it always fails + c, _, err := ws.DefaultDialer.Dial(u.String(), nil) + if err != nil { + log.Printf("Expected dial error") + } + c.Close() + + // Set up the client + c, _, err = ws.DefaultDialer.Dial(u.String(), nil) + if err != nil { + logger.Fatal().Err(err).Msg("Error dialing") + } + defer c.Close() + defer logger.Info().Str("id", "client").Msg("Client connection closed") + + api := broker.New(logger) + pool := websocket.NewPool(clientChan, logger) + client := websocket.NewClient(c) + clientChan <- client + + download := blcu.Download{} + download.SetAPI(api) + download.SetPool(pool) + + // Simulate sending a download request + request := blcu.DownloadRequest{Board: "test"} + err = download.Push(request) + if err != nil { + t.Fatal("Error pushing download request:", err) + } + + // Use a timeout for client read + done := make(chan struct{}) + go func() { + output, readErr := client.Read() + if readErr != nil { + logger.Error().Err(readErr).Msg("Client read failed") + done <- struct{}{} + return + } + if output.Topic != blcu.DownloadName { + t.Errorf("Expected topic %s, got %s", blcu.DownloadName, output.Topic) + } + if string(output.Payload) != "test" { + t.Error("Expected payload 'test', got", string(output.Payload)) + } + done <- struct{}{} + }() + + select { + case <-done: + logger.Info().Msg("Test completed successfully") + case <-time.After(3 * time.Second): + t.Error("Test timed out") + } +} + +func TestBLCUTopic_Download_ClientMessage(t *testing.T) { + download := blcu.Download{} + download.SetAPI(&MockAPI{}) + + download.ClientMessage(websocket.ClientId{0}, &websocket.Message{ + Topic: blcu.DownloadName, + Payload: []byte(`{"board":"test"}`), + }) + + if errorFlag { + t.Fatal("Output does not match") + } +} + +func TestBLCUTopic_Upload_Push(t *testing.T) { + logger := zerolog.New(os.Stdout).With().Timestamp().Logger() + clientChan := make(chan *websocket.Client) + u := tests_functions.StartServer(logger, "upload") + + // Set up the client + c, _, err := ws.DefaultDialer.Dial(u.String(), nil) + if err != nil { + logger.Fatal().Err(err).Msg("Error dialing") + } + defer c.Close() + defer logger.Info().Str("id", "client").Msg("Client connection closed") + + api := broker.New(logger) + pool := websocket.NewPool(clientChan, logger) + client := websocket.NewClient(c) + clientChan <- client + + upload := blcu.Upload{} + upload.SetAPI(api) + upload.SetPool(pool) + + // Simulate sending an upload request + request := blcu.UploadRequest{Board: "test", File: "dGVzdA=="} // "test" in base64 + err = upload.Push(request) + if err != nil { + t.Fatal("Error pushing upload request:", err) + } + + // Use a timeout for client read + done := make(chan struct{}) + go func() { + output, err := client.Read() + if err != nil { + logger.Error().Err(err).Msg("Client read failed") + done <- struct{}{} + return + } + if output.Topic != blcu.UploadName { + t.Errorf("Expected topic %s, got %s", blcu.UploadName, output.Topic) + } + if string(output.Payload) != "test" { + t.Error("Expected payload 'test', got", string(output.Payload)) + } + done <- struct{}{} + }() + + select { + case <-done: + logger.Info().Msg("Test completed successfully") + case <-time.After(3 * time.Second): + t.Error("Test timed out") + } +} + +func TestBLCUTopic_Upload_ClientMessage(t *testing.T) { + upload := blcu.Upload{} + upload.SetAPI(&MockAPI{}) + + // Use base64 encoded data as the frontend would send + payload := blcu.UploadRequest{Board: "test", File: "dGVzdA=="} // "test" in base64 + payloadBytes, _ := json.Marshal(payload) + + upload.ClientMessage(websocket.ClientId{0}, &websocket.Message{ + Topic: blcu.UploadName, + Payload: payloadBytes, + }) + + if errorFlag { + t.Fatal("Output does not match") + } +} diff --git a/backend/pkg/broker/topics/blcu/download.go b/backend/pkg/broker/topics/blcu/download.go new file mode 100644 index 000000000..8ee022956 --- /dev/null +++ b/backend/pkg/broker/topics/blcu/download.go @@ -0,0 +1,100 @@ +package blcu + +import ( + "encoding/json" + "fmt" + "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" + "github.com/HyperloopUPV-H8/h9-backend/pkg/boards" + "github.com/HyperloopUPV-H8/h9-backend/pkg/websocket" +) + +const DownloadName abstraction.BrokerTopic = "blcu/download" + +type Download struct { + pool *websocket.Pool + api abstraction.BrokerAPI + client websocket.ClientId +} + +func (download *Download) Topic() abstraction.BrokerTopic { + return DownloadName +} + +type DownloadRequest struct { + Board string `json:"board"` +} + +func (request DownloadRequest) Topic() abstraction.BrokerTopic { + return "blcu/downloadRequest" +} + +func (download *Download) Push(push abstraction.BrokerPush) error { + switch p := push.(type) { + case *boards.DownloadSuccess: + // Send success response with the downloaded data + response := map[string]interface{}{ + "percentage": 100, + "failure": false, + "file": p.Data, // The downloaded file data + } + payload, _ := json.Marshal(response) + err := download.pool.Write(download.client, websocket.Message{ + Topic: DownloadName, + Payload: payload, + }) + if err != nil { + return err + } + case *boards.DownloadFailure: + // Send failure response + response := map[string]interface{}{ + "percentage": 0, + "failure": true, + } + payload, _ := json.Marshal(response) + err := download.pool.Write(download.client, websocket.Message{ + Topic: DownloadName, + Payload: payload, + }) + if err != nil { + return err + } + } + + return nil +} + +func (download *Download) Pull(request abstraction.BrokerRequest) (abstraction.BrokerResponse, error) { + return nil, nil +} + +func (download *Download) ClientMessage(id websocket.ClientId, message *websocket.Message) { + download.client = id + + switch message.Topic { + case DownloadName: + err := download.handleDownload(message) + if err != nil { + fmt.Printf("error handling download: %v\n", err) + } + } +} + +func (download *Download) handleDownload(message *websocket.Message) error { + var downloadRequest DownloadRequest + err := json.Unmarshal(message.Payload, &downloadRequest) + if err != nil { + return err + } + + pushErr := download.api.UserPush(&downloadRequest) + return pushErr +} + +func (download *Download) SetPool(pool *websocket.Pool) { + download.pool = pool +} + +func (download *Download) SetAPI(api abstraction.BrokerAPI) { + download.api = api +} diff --git a/backend/pkg/broker/topics/blcu/register.go b/backend/pkg/broker/topics/blcu/register.go new file mode 100644 index 000000000..ee735b716 --- /dev/null +++ b/backend/pkg/broker/topics/blcu/register.go @@ -0,0 +1,22 @@ +package blcu + +import ( + "github.com/HyperloopUPV-H8/h9-backend/pkg/boards" + "github.com/HyperloopUPV-H8/h9-backend/pkg/broker" + "github.com/HyperloopUPV-H8/h9-backend/pkg/websocket" +) + +func RegisterTopics(b *broker.Broker, pool *websocket.Pool) { + upload := &Upload{} + upload.SetAPI(b) + upload.SetPool(pool) + download := &Download{} + download.SetAPI(b) + download.SetPool(pool) + b.AddTopic(UploadName, upload) + b.AddTopic(DownloadName, download) + b.AddTopic(boards.UploadSuccess{}.Topic(), upload) + b.AddTopic(boards.UploadFailure{}.Topic(), upload) + b.AddTopic(boards.DownloadSuccess{}.Topic(), download) + b.AddTopic(boards.DownloadFailure{}.Topic(), download) +} diff --git a/backend/pkg/broker/topics/blcu/upload.go b/backend/pkg/broker/topics/blcu/upload.go new file mode 100644 index 000000000..82e2b1278 --- /dev/null +++ b/backend/pkg/broker/topics/blcu/upload.go @@ -0,0 +1,124 @@ +package blcu + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" + "github.com/HyperloopUPV-H8/h9-backend/pkg/boards" + "github.com/HyperloopUPV-H8/h9-backend/pkg/websocket" +) + +const UploadName abstraction.BrokerTopic = "blcu/upload" + +type Upload struct { + pool *websocket.Pool + api abstraction.BrokerAPI + client websocket.ClientId +} + +func (upload *Upload) Topic() abstraction.BrokerTopic { + return UploadName +} + +type UploadRequest struct { + Board string `json:"board"` + File string `json:"file"` // Base64 encoded file data from frontend +} + +func (request UploadRequest) Topic() abstraction.BrokerTopic { + return "blcu/uploadRequest" +} + +// UploadRequestInternal is the internal representation with decoded data +type UploadRequestInternal struct { + Board string + Data []byte +} + +func (request UploadRequestInternal) Topic() abstraction.BrokerTopic { + return "blcu/uploadRequest" +} + +func (upload *Upload) Push(push abstraction.BrokerPush) error { + switch push.(type) { + case *boards.UploadSuccess: + // Send success response + response := map[string]interface{}{ + "percentage": 100, + "failure": false, + } + payload, _ := json.Marshal(response) + err := upload.pool.Write(upload.client, websocket.Message{ + Topic: UploadName, + Payload: payload, + }) + if err != nil { + return err + } + + case *boards.UploadFailure: + // Send failure response + response := map[string]interface{}{ + "percentage": 0, + "failure": true, + } + payload, _ := json.Marshal(response) + err := upload.pool.Write(upload.client, websocket.Message{ + Topic: UploadName, + Payload: payload, + }) + if err != nil { + return err + } + } + + return nil +} + +func (upload *Upload) Pull(request abstraction.BrokerRequest) (abstraction.BrokerResponse, error) { + return nil, nil +} + +func (upload *Upload) ClientMessage(id websocket.ClientId, message *websocket.Message) { + upload.client = id + + switch message.Topic { + case UploadName: + err := upload.handleUpload(message) + if err != nil { + fmt.Printf("error handling download: %v\n", err) + } + } +} + +func (upload *Upload) handleUpload(message *websocket.Message) error { + var uploadRequest UploadRequest + err := json.Unmarshal(message.Payload, &uploadRequest) + if err != nil { + return err + } + + // Decode base64 file data + fileData, err := base64.StdEncoding.DecodeString(uploadRequest.File) + if err != nil { + return fmt.Errorf("failed to decode base64 file data: %w", err) + } + + // Create the internal upload event with decoded data + internalRequest := &UploadRequestInternal{ + Board: uploadRequest.Board, + Data: fileData, + } + + pushErr := upload.api.UserPush(internalRequest) + return pushErr +} + +func (upload *Upload) SetPool(pool *websocket.Pool) { + upload.pool = pool +} + +func (upload *Upload) SetAPI(api abstraction.BrokerAPI) { + upload.api = api +} diff --git a/backend/pkg/broker/topics/logger/enable.go b/backend/pkg/broker/topics/logger/enable.go index 57b30f690..adc74cdb4 100644 --- a/backend/pkg/broker/topics/logger/enable.go +++ b/backend/pkg/broker/topics/logger/enable.go @@ -2,6 +2,7 @@ package logger import ( "encoding/json" + "fmt" "sync" "sync/atomic" @@ -10,7 +11,6 @@ import ( "github.com/HyperloopUPV-H8/h9-backend/pkg/websocket" "github.com/google/uuid" ws "github.com/gorilla/websocket" - "github.com/rs/zerolog" ) const EnableName abstraction.BrokerTopic = "logger/enable" @@ -24,15 +24,13 @@ type Enable struct { subscribers map[websocket.ClientId]struct{} api abstraction.BrokerAPI data_logger *data_logger.Logger - baseLogger zerolog.Logger } -func NewEnableTopic(baseLogger zerolog.Logger) *Enable { +func NewEnableTopic() *Enable { enable := &Enable{ isRunning: &atomic.Bool{}, connectionMx: new(sync.Mutex), subscribers: make(map[websocket.ClientId]struct{}), - baseLogger: baseLogger, } enable.isRunning.Store(false) return enable @@ -55,37 +53,19 @@ func (enable *Enable) ClientMessage(id websocket.ClientId, message *websocket.Me case EnableName: err := enable.handleToggle(id, message) if err != nil { - enable.baseLogger.Error().Err(err).Msg("error handling logger/enable") + fmt.Printf("error handling logger: %v\n", err) } case ResponseName: enable.connectionMx.Lock() defer enable.connectionMx.Unlock() - enable.baseLogger.Debug().Msgf("logger/response subscribed %s", uuid.UUID(id).String()) + fmt.Printf("logger/response subscribed %s\n", uuid.UUID(id).String()) enable.subscribers[id] = struct{}{} - // Get current logger state - payload, err := json.Marshal(enable.isRunning.Load()) - if err != nil { - enable.baseLogger.Error().Err(err).Msg("error marshaling logger state") - } - - // Prepare message - message := websocket.Message{ - Topic: ResponseName, - Payload: payload, - } - - // Send current logger state to client that just subscribed - err = enable.pool.Write(id, message) - if err != nil { - enable.baseLogger.Error().Err(err).Msg("error sending logger state to client") - } - case VariablesName: err := enable.handleVariables(id, message) if err != nil { - enable.baseLogger.Error().Err(err).Msg("error handling logger/variables") + fmt.Printf("error handling logger/variables: %v\n", err) } default: enable.connectionMx.Lock() @@ -93,7 +73,7 @@ func (enable *Enable) ClientMessage(id websocket.ClientId, message *websocket.Me enable.pool.Disconnect(id, ws.CloseUnsupportedData, "unsupported topic") delete(enable.subscribers, id) - enable.baseLogger.Debug().Msgf("logger/response unsubscribed %s", uuid.UUID(id).String()) + fmt.Printf("logger/response unsubscribed %s\n", uuid.UUID(id).String()) } } @@ -104,25 +84,11 @@ func (enable *Enable) handleToggle(_ websocket.ClientId, message *websocket.Mess return err } - // If we are already in the state the user wants, - // just confirm it immediately and don't bother the rest of the system. - if enable.isRunning.Load() == request { - return enable.broadcastState() - } - status := newStatus(request) go enable.api.UserPush(status) go func() { - response := <-status.response - - enable.isRunning.Store(response) - - if request && response { - // Successfully started logging: do nothing because NotifyStarted already broadcasts the state - return - } - + enable.isRunning.Store(<-status.response) enable.broadcastState() }() return nil @@ -143,11 +109,6 @@ func (enable *Enable) NotifyStarted() error { return enable.broadcastState() } -func (enable *Enable) NotifyStopped() error { - enable.isRunning.Store(false) - return enable.broadcastState() -} - func (enable *Enable) broadcastState() error { payload, err := json.Marshal(enable.isRunning.Load()) if err != nil { @@ -172,8 +133,7 @@ func (enable *Enable) broadcastState() error { for _, id := range flaged { enable.pool.Disconnect(id, ws.CloseInternalServerErr, "client disconnected") delete(enable.subscribers, id) - - enable.baseLogger.Debug().Msgf("logger/response unsubscribed %s", uuid.UUID(id).String()) + fmt.Printf("logger/response unsubscribed %s\n", uuid.UUID(id).String()) } return nil diff --git a/backend/pkg/broker/topics/logger/logger_test.go b/backend/pkg/broker/topics/logger/logger_test.go index 22829bf76..abc74da2a 100644 --- a/backend/pkg/broker/topics/logger/logger_test.go +++ b/backend/pkg/broker/topics/logger/logger_test.go @@ -2,15 +2,12 @@ package logger_test import ( "encoding/json" - "log" - "testing" - "time" - "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" data "github.com/HyperloopUPV-H8/h9-backend/pkg/broker/topics/logger" "github.com/HyperloopUPV-H8/h9-backend/pkg/websocket" - - trace "github.com/rs/zerolog/log" + "log" + "testing" + "time" ) var errorFlag bool @@ -41,7 +38,7 @@ func TestLoggerTopic_ClientMessage(t *testing.T) { errorFlag = true api := MockAPI{} - loggerTopic := data.NewEnableTopic(trace.Logger) + loggerTopic := data.NewEnableTopic() loggerTopic.SetAPI(api) payload, _ := json.Marshal(true) diff --git a/backend/pkg/broker/topics/message/update.go b/backend/pkg/broker/topics/message/update.go index 40ae5479a..1adecd145 100644 --- a/backend/pkg/broker/topics/message/update.go +++ b/backend/pkg/broker/topics/message/update.go @@ -134,7 +134,7 @@ func (push *pushStruct) Data(boardName string) wrapper { Kind: "info", Payload: "Order Sent", Board: boardName, - Name: fmt.Sprintf("%d", data.Id()), + Name: string(data.Id()), Timestamp: protection.NowTimestamp(), } } diff --git a/backend/pkg/logger/logger.go b/backend/pkg/logger/logger.go index d1e9fd68d..2dfd626ff 100644 --- a/backend/pkg/logger/logger.go +++ b/backend/pkg/logger/logger.go @@ -1,4 +1,3 @@ -// Package logger provides logging functionality for the HyperLoop backend. package logger import ( @@ -13,7 +12,7 @@ import ( const ( Name = "loggerHandler" HandlerName = "logger" - TimestampFormat = "2006-01-02T15-04-05" // ISO 8601 date for ensuring correct lexicographical order + TimestampFormat = "02-Jan-2006_15-04-05.000" ) // Logger is a struct that implements the abstraction.Logger interface @@ -22,7 +21,7 @@ type Logger struct { running *atomic.Bool subloggersLock *sync.RWMutex // The subloggers are only the loggers selected at the start of the log - subloggers abstraction.SubloggersMap + subloggers map[abstraction.LoggerName]abstraction.Logger trace zerolog.Logger @@ -34,14 +33,14 @@ type Logger struct { ***************/ var _ abstraction.Logger = &Logger{} -// Timestamp is used on subloggers to get the current timestamp for folder or file names +// Used on subloggers to get the current timestamp for folder or file names var Timestamp = time.Now() var BasePath = "." func (Logger) HandlerName() string { return HandlerName } -func NewLogger(keys abstraction.SubloggersMap, baseLogger zerolog.Logger) *Logger { +func NewLogger(keys map[abstraction.LoggerName]abstraction.Logger, baseLogger zerolog.Logger) *Logger { trace := baseLogger.Sample(zerolog.LevelSampler{ TraceSampler: zerolog.RandomSampler(25000), DebugSampler: zerolog.RandomSampler(1), @@ -168,7 +167,7 @@ func (logger *Logger) Stop() error { return nil } -// ConfigureLogger configures the logger attributes before initializing it. +// Configures the logger atributes before inicialicing it func ConfigureLogger(unit TimeUnit, basePath string) { // Start the sublogger diff --git a/backend/pkg/logger/logger_test.go b/backend/pkg/logger/logger_test.go index 729f89119..ea89b33a6 100644 --- a/backend/pkg/logger/logger_test.go +++ b/backend/pkg/logger/logger_test.go @@ -114,7 +114,7 @@ func TestLoggerGroup_Errors(t *testing.T) { _ = chdirTemp(t) // Change to a temporary directory // Logger with empty map → PushRecord should return error (no sublogger) - lEmpty := logger.NewLogger(abstraction.SubloggersMap{}, zerolog.New(os.Stdout)) + lEmpty := logger.NewLogger(map[abstraction.LoggerName]abstraction.Logger{}, zerolog.New(os.Stdout)) err := lEmpty.PushRecord(&mockRecord{n: abstraction.LoggerName("missing")}) if err == nil { t.Fatalf("expected error when PushRecord to non-existent sublogger, got nil") @@ -122,7 +122,7 @@ func TestLoggerGroup_Errors(t *testing.T) { // Logger whose sublogger returns error on Start → Start should propagate the error wantErr := os.ErrPermission - badMap := abstraction.SubloggersMap{ + badMap := map[abstraction.LoggerName]abstraction.Logger{ abstraction.LoggerName("bad"): &mockSublogger{startErr: wantErr}, } lBad := logger.NewLogger(badMap, zerolog.New(os.Stdout)) diff --git a/backend/pkg/transport/constructor.go b/backend/pkg/transport/constructor.go index d555f40ef..a582a093f 100644 --- a/backend/pkg/transport/constructor.go +++ b/backend/pkg/transport/constructor.go @@ -12,7 +12,7 @@ import ( func NewTransport(baseLogger zerolog.Logger) *Transport { transport := &Transport{ - connectionsMx: &sync.RWMutex{}, + connectionsMx: &sync.Mutex{}, connections: make(map[abstraction.TransportTarget]net.Conn), idToTarget: make(map[abstraction.PacketId]abstraction.TransportTarget), ipToTarget: make(map[string]abstraction.TransportTarget), diff --git a/backend/pkg/transport/network/tftp/.gitignore b/backend/pkg/transport/network/tftp/.gitignore deleted file mode 100644 index 62c74bcbe..000000000 --- a/backend/pkg/transport/network/tftp/.gitignore +++ /dev/null @@ -1 +0,0 @@ -testfile.txt \ No newline at end of file diff --git a/backend/pkg/transport/network/tftp/testfile.txt b/backend/pkg/transport/network/tftp/testfile.txt new file mode 100644 index 000000000..b6fc4c620 --- /dev/null +++ b/backend/pkg/transport/network/tftp/testfile.txt @@ -0,0 +1 @@ +hello \ No newline at end of file diff --git a/backend/pkg/transport/packet/blcu/decoder.go b/backend/pkg/transport/packet/blcu/decoder.go new file mode 100644 index 000000000..273bf48c8 --- /dev/null +++ b/backend/pkg/transport/packet/blcu/decoder.go @@ -0,0 +1,20 @@ +package blcu + +import ( + "io" + + "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" +) + +// Decoder is a decoder for the blcu Ack packet +type Decoder struct{} + +// NewDecoder creates a new Decoder +func NewDecoder() *Decoder { + return &Decoder{} +} + +// Decode decodes the next packet on reader and returns the corresponding blcuAck. +func (decoder *Decoder) Decode(id abstraction.PacketId, reader io.Reader) (abstraction.Packet, error) { + return NewAck(id), nil +} diff --git a/backend/pkg/transport/packet/blcu/packet.go b/backend/pkg/transport/packet/blcu/packet.go new file mode 100644 index 000000000..ebe5c57c9 --- /dev/null +++ b/backend/pkg/transport/packet/blcu/packet.go @@ -0,0 +1,22 @@ +package blcu + +import "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" + +// Ack is the blcu Ack message sent when the blcu is ready to create a tftp connection. +// +// the packet just has the ID. +type Ack struct { + id abstraction.PacketId +} + +// NewAck creates a new blcu ack packet with the given id +func NewAck(id abstraction.PacketId) *Ack { + return &Ack{ + id: id, + } +} + +// Id returns the ID of the packet +func (packet *Ack) Id() abstraction.PacketId { + return packet.id +} diff --git a/backend/pkg/transport/packet/data/decoder.go b/backend/pkg/transport/packet/data/decoder.go index 70722a647..a4c11699a 100644 --- a/backend/pkg/transport/packet/data/decoder.go +++ b/backend/pkg/transport/packet/data/decoder.go @@ -35,7 +35,7 @@ func (decoder *Decoder) Decode(id abstraction.PacketId, reader io.Reader) (abstr return nil, ErrUnexpectedId{Id: id} } - packet := GetPacket(id) + packet := NewPacket(id) for _, value := range descriptor { val, err := value.Decode(decoder.endianness, reader) if err != nil { diff --git a/backend/pkg/transport/packet/data/packet.go b/backend/pkg/transport/packet/data/packet.go index 75178e0a6..d44f3c7df 100644 --- a/backend/pkg/transport/packet/data/packet.go +++ b/backend/pkg/transport/packet/data/packet.go @@ -1,7 +1,6 @@ package data import ( - "sync" "time" "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" @@ -28,16 +27,6 @@ func NewPacket(id abstraction.PacketId) *Packet { } } -var packetPool = sync.Pool{ - New: func() any { - return &Packet{ - values: make(map[ValueName]Value), - enabled: make(map[ValueName]bool), - } - }, -} - - // NewPacketWithValues creates a new data packet with the given values func NewPacketWithValues(id abstraction.PacketId, values map[ValueName]Value, enabled map[ValueName]bool) *Packet { return &Packet{ @@ -73,35 +62,3 @@ func (packet *Packet) SetTimestamp(timestamp time.Time) *Packet { packet.timestamp = timestamp return packet } - -func (packet *Packet) Reset() { - clear(packet.values) - clear(packet.enabled) - packet.id = 0 - packet.timestamp = time.Time{} -} - -func GetPacket(id abstraction.PacketId) *Packet { - p := packetPool.Get().(*Packet) - if p.values == nil { - p.values = make(map[ValueName]Value) - } else { - clear(p.values) - } - if p.enabled == nil { - p.enabled = make(map[ValueName]bool) - } else { - clear(p.enabled) - } - p.id = id - p.timestamp = time.Now() - return p -} - -func ReleasePacket(p *Packet) { - if p == nil { - return - } - p.Reset() - packetPool.Put(p) -} diff --git a/backend/pkg/transport/presentation/decoder.go b/backend/pkg/transport/presentation/decoder.go index bc30a1272..14e2924ec 100644 --- a/backend/pkg/transport/presentation/decoder.go +++ b/backend/pkg/transport/presentation/decoder.go @@ -5,6 +5,7 @@ import ( "io" "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" + "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/packet/blcu" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/packet/data" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/packet/order" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/packet/protection" @@ -19,6 +20,7 @@ type PacketDecoder interface { // Type assertions to check packet decoders follows the Decoder interface var _ PacketDecoder = &data.Decoder{} +var _ PacketDecoder = &blcu.Decoder{} var _ PacketDecoder = &order.Decoder{} var _ PacketDecoder = &protection.Decoder{} var _ PacketDecoder = &state.Decoder{} diff --git a/backend/pkg/transport/presentation/decoder_test.go b/backend/pkg/transport/presentation/decoder_test.go index 829f85b89..8df16058d 100644 --- a/backend/pkg/transport/presentation/decoder_test.go +++ b/backend/pkg/transport/presentation/decoder_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" + "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/packet/blcu" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/packet/data" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/packet/order" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/packet/protection" @@ -28,7 +29,22 @@ func TestDecoder(t *testing.T) { endianness := binary.LittleEndian testcases := []testcase{ - + { + name: "blcu ack", + input: bytes.NewReader([]byte{0x01, 0x00}), + output: []abstraction.Packet{ + blcu.NewAck(1), + }, + }, + { + name: "multiple blcu ack", + input: bytes.NewReader([]byte{0x01, 0x00, 0x01, 0x00, 0x01, 0x00}), + output: []abstraction.Packet{ + blcu.NewAck(1), + blcu.NewAck(1), + blcu.NewAck(1), + }, + }, { name: "state orders add", input: bytes.NewReader([]byte{0x03, 0x00, 0x01, 0x00, 0xFF, 0xFF}), @@ -632,6 +648,7 @@ func TestDecoder(t *testing.T) { } // getDecoder generates a mock Decoder with the following packet IDs: +// 1 - blcuAck // 3 - add state order // 4 - remove state order // 5 - protection warning @@ -642,6 +659,8 @@ func getDecoder(endianness binary.ByteOrder) *presentation.Decoder { nullLogger := zerolog.New(io.Discard) decoder := presentation.NewDecoder(endianness, nullLogger) + decoder.SetPacketDecoder(1, blcu.NewDecoder()) + ordersDecoder := order.NewDecoder(endianness) ordersDecoder.SetActionId(3, ordersDecoder.DecodeAdd) ordersDecoder.SetActionId(4, ordersDecoder.DecodeRemove) diff --git a/backend/pkg/transport/presentation/encoder.go b/backend/pkg/transport/presentation/encoder.go index 7618628d8..2328f0442 100644 --- a/backend/pkg/transport/presentation/encoder.go +++ b/backend/pkg/transport/presentation/encoder.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/binary" "io" - "sync" "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" "github.com/rs/zerolog" @@ -18,8 +17,7 @@ type Encoder struct { idToEncoder map[abstraction.PacketId]PacketEncoder endianness binary.ByteOrder - logger zerolog.Logger - bufPool sync.Pool + logger zerolog.Logger } // TODO: improve constructor @@ -30,9 +28,6 @@ func NewEncoder(endianness binary.ByteOrder, baseLogger zerolog.Logger) *Encoder endianness: endianness, logger: baseLogger, - bufPool: sync.Pool{ - New: func() any { return new(bytes.Buffer) }, - }, } } @@ -42,41 +37,23 @@ func (encoder *Encoder) SetPacketEncoder(id abstraction.PacketId, enc PacketEnco encoder.logger.Trace().Uint16("id", uint16(id)).Type("encoder", enc).Msg("set encoder") } -// Encode encodes the provided packet into a pooled buffer. Callers must release -// the buffer via ReleaseBuffer once they are done using the returned data. -func (encoder *Encoder) Encode(packet abstraction.Packet) (*bytes.Buffer, error) { +// Encode encodes the provided packet into a byte slice, returning any errors +func (encoder *Encoder) Encode(packet abstraction.Packet) ([]byte, error) { enc, ok := encoder.idToEncoder[packet.Id()] if !ok { encoder.logger.Warn().Uint16("id", uint16(packet.Id())).Msg("no encoder set") return nil, ErrUnexpectedId{Id: packet.Id()} } - bufferAny := encoder.bufPool.Get() - buffer := bufferAny.(*bytes.Buffer) - buffer.Reset() + buffer := new(bytes.Buffer) err := binary.Write(buffer, encoder.endianness, packet.Id()) if err != nil { encoder.logger.Error().Stack().Err(err).Uint16("id", uint16(packet.Id())).Msg("buffering id") - encoder.ReleaseBuffer(buffer) - return nil, err + return buffer.Bytes(), err } encoder.logger.Debug().Uint16("id", uint16(packet.Id())).Type("encoder", enc).Msg("encoding") err = enc.Encode(packet, buffer) - if err != nil { - encoder.ReleaseBuffer(buffer) - return nil, err - } - - return buffer, nil -} - -// ReleaseBuffer returns a buffer obtained from Encode back to the pool. -func (encoder *Encoder) ReleaseBuffer(buffer *bytes.Buffer) { - if buffer == nil { - return - } - buffer.Reset() - encoder.bufPool.Put(buffer) + return buffer.Bytes(), err } diff --git a/backend/pkg/transport/presentation/encoder_test.go b/backend/pkg/transport/presentation/encoder_test.go index d0420c913..ecff59dd3 100644 --- a/backend/pkg/transport/presentation/encoder_test.go +++ b/backend/pkg/transport/presentation/encoder_test.go @@ -379,13 +379,12 @@ func TestEncoder(t *testing.T) { output := make([]byte, 0, len(test.output)) for i := 0; i < len(test.input); i++ { - buf, err := encoder.Encode(test.input[i]) + encoded, err := encoder.Encode(test.input[i]) if err != nil { t.Fatalf("\nError encoding (%d) packet: %s\n", i+1, err) } - output = append(output, buf.Bytes()...) - encoder.ReleaseBuffer(buf) + output = append(output, encoded...) } diff --git a/backend/pkg/transport/transport.go b/backend/pkg/transport/transport.go index d4020a5d6..bc11b11c8 100644 --- a/backend/pkg/transport/transport.go +++ b/backend/pkg/transport/transport.go @@ -31,7 +31,7 @@ type Transport struct { decoder *presentation.Decoder encoder *presentation.Encoder - connectionsMx *sync.RWMutex + connectionsMx *sync.Mutex connections map[abstraction.TransportTarget]net.Conn ipToTarget map[string]abstraction.TransportTarget @@ -45,27 +45,22 @@ type Transport struct { logger zerolog.Logger - byteReaderPool sync.Pool - errChan chan error } -// For tests -var zeroTime time.Time - // HandleClient connects to the specified client and handles its messages. This method blocks. // This method will continuously try to reconnect to the client if it disconnects, // applying exponential backoff between attempts. func (transport *Transport) HandleClient(config tcp.ClientConfig, remote string) error { client := tcp.NewClient(remote, config, transport.logger) - clientLogger := transport.logger.With().Str("remoteAddress", remote).Logger() - defer clientLogger.Warn().Msg("abort connection") + defer transport.logger.Warn().Str("remoteAddress", remote).Msg("abort connection") var hasConnected = false for { conn, err := client.Dial() if err != nil { - clientLogger.Debug().Stack().Err(err).Msg("dial failed") + transport.logger.Debug().Stack().Err(err).Str("remoteAddress", remote).Msg("dial failed") + // Only return if reconnection is disabled if !config.TryReconnect { if hasConnected { @@ -78,7 +73,7 @@ func (transport *Transport) HandleClient(config tcp.ClientConfig, remote string) // For ErrTooManyRetries, we still want to continue retrying // The client will reset its retry counter on the next Dial() call if _, ok := err.(tcp.ErrTooManyRetries); ok { - clientLogger.Warn().Msg("reached max retries, will continue attempting to reconnect") + transport.logger.Warn().Str("remoteAddress", remote).Msg("reached max retries, will continue attempting to reconnect") // Add a longer delay before restarting the retry cycle time.Sleep(config.ConnectionBackoffFunction(config.MaxConnectionRetries)) } @@ -90,12 +85,12 @@ func (transport *Transport) HandleClient(config tcp.ClientConfig, remote string) err = transport.handleTCPConn(conn) if errors.Is(err, error(ErrTargetAlreadyConnected{})) { - clientLogger.Warn().Stack().Err(err).Msg("multiple connections for same target") + transport.logger.Warn().Stack().Err(err).Str("remoteAddress", remote).Msg("multiple connections for same target") transport.errChan <- err return err } if err != nil { - clientLogger.Debug().Stack().Err(err).Msg("connection lost") + transport.logger.Debug().Stack().Err(err).Str("remoteAddress", remote).Msg("connection lost") if !config.TryReconnect { transport.SendFault() transport.errChan <- err @@ -205,7 +200,7 @@ func (transport *Transport) targetFromTCPConn(conn net.Conn) (abstraction.Transp } // rejectIfConnectedTCPConn closes and rejects conn if target already has an active connection. -func (transport *Transport) rejectIfConnectedTCPConn(target abstraction.TransportTarget, conn net.Conn, logger zerolog.Logger) error { +func (transport *Transport) rejectIfConnectedTCPConn(target abstraction.TransportTarget, conn net.Conn, logger zerolog.Logger,) error { transport.connectionsMx.Lock() defer transport.connectionsMx.Unlock() @@ -238,7 +233,7 @@ func (transport *Transport) registerTCPConn(target abstraction.TransportTarget, func (transport *Transport) readLoopTCPConn(conn net.Conn, logger zerolog.Logger) { from := conn.RemoteAddr().String() to := conn.LocalAddr().String() - + go func() { for { packet, err := transport.decoder.DecodeNext(conn) @@ -259,14 +254,11 @@ func (transport *Transport) readLoopTCPConn(conn net.Conn, logger zerolog.Logger logger.Trace().Type("type", packet).Msg("packet") transport.api.Notification(NewPacketNotification(packet, from, to, time.Now())) - - if dataPacket, ok := packet.(*data.Packet); ok { - data.ReleasePacket(dataPacket) - } } }() } + // SendMessage triggers an event to send something to the vehicle. Some messages // might additional means to pass information around (e.g. file read and write) func (transport *Transport) SendMessage(message abstraction.TransportMessage) error { @@ -275,6 +267,10 @@ func (transport *Transport) SendMessage(message abstraction.TransportMessage) er switch msg := message.(type) { case PacketMessage: err = transport.handlePacketEvent(msg) + case FileWriteMessage: + err = transport.handleFileWrite(msg) + case FileReadMessage: + err = transport.handleFileRead(msg) default: err = ErrUnrecognizedEvent{message.Event()} } @@ -293,31 +289,30 @@ func (transport *Transport) handlePacketEvent(message PacketMessage) error { if message.Id() == 0 { eventLogger.Info().Msg("broadcasting packet id 0") - buf, err := transport.encoder.Encode(message.Packet) + data, err := transport.encoder.Encode(message.Packet) if err != nil { eventLogger.Error().Stack().Err(err).Msg("encode") transport.errChan <- err return err } - defer transport.encoder.ReleaseBuffer(buf) - data := buf.Bytes() - transport.connectionsMx.RLock() - defer transport.connectionsMx.RUnlock() + transport.connectionsMx.Lock() + defer transport.connectionsMx.Unlock() for target, conn := range transport.connections { - targetName := string(target) + eventLogger := eventLogger.With().Str("target", string(target)).Logger() + totalWritten := 0 for totalWritten < len(data) { n, err := conn.Write(data[totalWritten:]) - eventLogger.Trace().Str("target", targetName).Int("amount", n).Msg("written chunk") + eventLogger.Trace().Int("amount", n).Msg("written chunk") totalWritten += n if err != nil { - eventLogger.Error().Str("target", targetName).Stack().Err(err).Msg("write") + eventLogger.Error().Stack().Err(err).Msg("write") transport.errChan <- err return err } } - eventLogger.Info().Str("target", targetName).Msg("sent") + eventLogger.Info().Msg("sent") } return nil } @@ -333,8 +328,8 @@ func (transport *Transport) handlePacketEvent(message PacketMessage) error { eventLogger.Info().Msg("sending") conn, err := func() (net.Conn, error) { - transport.connectionsMx.RLock() - defer transport.connectionsMx.RUnlock() + transport.connectionsMx.Lock() + defer transport.connectionsMx.Unlock() conn, ok := transport.connections[target] if !ok { eventLogger.Warn().Msg("target not connected") @@ -349,14 +344,12 @@ func (transport *Transport) handlePacketEvent(message PacketMessage) error { return err } - buf, err := transport.encoder.Encode(message.Packet) + data, err := transport.encoder.Encode(message.Packet) if err != nil { eventLogger.Error().Stack().Err(err).Msg("encode") transport.errChan <- err return err } - defer transport.encoder.ReleaseBuffer(buf) - data := buf.Bytes() totalWritten := 0 for totalWritten < len(data) { @@ -374,6 +367,24 @@ func (transport *Transport) handlePacketEvent(message PacketMessage) error { return nil } +// handleFileWrite writes a file through tftp to the blcu +func (transport *Transport) handleFileWrite(message FileWriteMessage) error { + _, err := transport.tftp.WriteFile(message.Filename(), tftp.BinaryMode, message) + if err != nil { + transport.errChan <- err + } + return err +} + +// handleFileRead reads a file through tftp from the blcu +func (transport *Transport) handleFileRead(message FileReadMessage) error { + _, err := transport.tftp.ReadFile(message.Filename(), tftp.BinaryMode, message) + if err != nil { + transport.errChan <- err + } + return err +} + // HandleSniffer starts listening for packets on the provided sniffer and handles them. // // This function will block until the sniffer is closed @@ -391,7 +402,7 @@ func (transport *Transport) HandleSniffer(sniffer *sniffer.Sniffer) { func (transport *Transport) HandleUDPServer(server *udp.Server) { packetsCh := server.GetPackets() errorsCh := server.GetErrors() - + for { select { case packet := <-packetsCh: @@ -402,30 +413,14 @@ func (transport *Transport) HandleUDPServer(server *udp.Server) { } } -func (transport *Transport) replicateFault(packet abstraction.Packet, logger zerolog.Logger) { - logger.Info().Msg("replicating packet with id 0 to all boards") - err := transport.handlePacketEvent(NewPacketMessage(packet)) - if err != nil { - logger.Error().Err(err).Msg("failed to replicate packet") - } -} - // handleUDPPacket handles a single UDP packet received by the UDP server func (transport *Transport) handleUDPPacket(udpPacket udp.Packet) { srcAddr := fmt.Sprintf("%s:%d", udpPacket.SourceIP, udpPacket.SourcePort) dstAddr := fmt.Sprintf("%s:%d", udpPacket.DestIP, udpPacket.DestPort) - + // Create a reader from the payload - readerAny := transport.byteReaderPool.Get() - var reader *bytes.Reader - if readerAny != nil { - reader = readerAny.(*bytes.Reader) - reader.Reset(udpPacket.Payload) - } else { - reader = bytes.NewReader(udpPacket.Payload) - } - defer transport.byteReaderPool.Put(reader) - + reader := bytes.NewReader(udpPacket.Payload) + // Decode the packet packet, err := transport.decoder.DecodeNext(reader) if err != nil { @@ -437,18 +432,18 @@ func (transport *Transport) handleUDPPacket(udpPacket udp.Packet) { transport.errChan <- err return } - + // Intercept packets with id == 0 and replicate if transport.propagateFault && packet.Id() == 0 { - transport.replicateFault(packet, transport.logger) + transport.logger.Info().Msg("replicating packet with id 0 to all boards") + err := transport.handlePacketEvent(NewPacketMessage(packet)) + if err != nil { + transport.logger.Error().Err(err).Msg("failed to replicate packet") + } } - + // Send notification transport.api.Notification(NewPacketNotification(packet, srcAddr, dstAddr, udpPacket.Timestamp)) - - if dataPacket, ok := packet.(*data.Packet); ok { - data.ReleasePacket(dataPacket) - } } // handleConversation is called when the sniffer detects a new conversation and handles its specific packets @@ -468,15 +463,14 @@ func (transport *Transport) handleConversation(socket network.Socket, reader io. // Intercept packets with id == 0 and replicate if transport.propagateFault && packet.Id() == 0 { - transport.replicateFault(packet, transport.logger) + conversationLogger.Info().Msg("replicating packet with id 0 to all boards") + err := transport.handlePacketEvent(NewPacketMessage(packet)) + if err != nil { + conversationLogger.Error().Err(err).Msg("failed to replicate packet") + } } - // Send notification transport.api.Notification(NewPacketNotification(packet, srcAddr, dstAddr, time.Now())) - - if dataPacket, ok := packet.(*data.Packet); ok { - data.ReleasePacket(dataPacket) - } } }() } diff --git a/backend/pkg/transport/transport_test.go b/backend/pkg/transport/transport_test.go index 8cde13a44..0a1c9fd6f 100644 --- a/backend/pkg/transport/transport_test.go +++ b/backend/pkg/transport/transport_test.go @@ -1,29 +1,18 @@ package transport import ( - "bytes" "context" "encoding/binary" "fmt" - "io" "net" - "os" - "strings" "sync" "testing" "time" "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" - "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/network" - "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/network/sniffer" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/network/tcp" - "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/network/tftp" - "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/network/udp" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/packet/data" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/presentation" - "github.com/google/gopacket/layers" - "github.com/google/gopacket/pcap" - "github.com/google/gopacket/pcapgo" "github.com/rs/zerolog" ) @@ -86,26 +75,6 @@ func (api *TestTransportAPI) Reset() { api.notifications = api.notifications[:0] } -// simpleConn is a net.Conn with specified local and remote addresses -type simpleConn struct { - net.Conn - local net.Addr - remote net.Addr -} - -func (c *simpleConn) LocalAddr() net.Addr { return c.local } -func (c *simpleConn) RemoteAddr() net.Addr { return c.remote } - -func defaultLogger() zerolog.Logger { - return zerolog.New(zerolog.Nop()) -} - -// noopTransportAPI is a no-op implementation of abstraction.TransportAPI -type noopTransportAPI struct{} - -func (noopTransportAPI) Notification(abstraction.TransportNotification) {} -func (noopTransportAPI) ConnectionUpdate(abstraction.TransportTarget, bool) {} - // MockBoardServer simulates a vehicle board type MockBoardServer struct { address string @@ -122,8 +91,8 @@ func NewMockBoardServer(address string) *MockBoardServer { logger := zerolog.Nop() enc := presentation.NewEncoder(binary.BigEndian, logger) - dec := presentation.NewDecoder(binary.BigEndian, logger) - wireTestPacketCodec(enc, dec, abstraction.PacketId(100)) + dec := presentation.NewDecoder(binary.BigEndian, logger) + wireTestPacketCodec(enc, dec, abstraction.PacketId(100)) return &MockBoardServer{ address: address, @@ -137,47 +106,47 @@ func NewMockBoardServer(address string) *MockBoardServer { func (s *MockBoardServer) Start() error { s.mu.Lock() defer s.mu.Unlock() - + if s.running { return fmt.Errorf("server already running") } - + listener, err := net.Listen("tcp", s.address) if err != nil { return fmt.Errorf("failed to listen on %s: %w", s.address, err) } - + s.listener = listener s.running = true - + go s.acceptLoop() - + return nil } func (s *MockBoardServer) Stop() error { s.mu.Lock() defer s.mu.Unlock() - + if !s.running { return nil } - + s.running = false - + // Close all connections for _, conn := range s.connections { conn.Close() } s.connections = s.connections[:0] - + // Close listener if s.listener != nil { err := s.listener.Close() s.listener = nil return err } - + return nil } @@ -193,11 +162,11 @@ func (s *MockBoardServer) acceptLoop() { } continue } - + s.mu.Lock() s.connections = append(s.connections, conn) s.mu.Unlock() - + go s.handleConnection(conn) } } @@ -215,19 +184,19 @@ func (s *MockBoardServer) handleConnection(conn net.Conn) { } s.mu.Unlock() }() - + for { s.mu.RLock() running := s.running s.mu.RUnlock() - + if !running { return } - + // Set read timeout to avoid blocking forever conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) - + packet, err := s.decoder.DecodeNext(conn) if err != nil { if netErr, ok := err.(net.Error); ok && netErr.Timeout() { @@ -235,7 +204,7 @@ func (s *MockBoardServer) handleConnection(conn net.Conn) { } return } - + s.mu.Lock() s.packetsRecv = append(s.packetsRecv, packet) s.mu.Unlock() @@ -263,17 +232,17 @@ func createTestTransport(t *testing.T) (*Transport, *TestTransportAPI) { logger := zerolog.New(zerolog.Nop()).With().Timestamp().Logger() enc := presentation.NewEncoder(binary.BigEndian, logger) - dec := presentation.NewDecoder(binary.BigEndian, logger) - wireTestPacketCodec(enc, dec, abstraction.PacketId(100)) - wireTestPacketCodec(enc, dec, abstraction.PacketId(0)) + dec := presentation.NewDecoder(binary.BigEndian, logger) + wireTestPacketCodec(enc, dec, abstraction.PacketId(100)) + transport := NewTransport(logger). WithEncoder(enc). WithDecoder(dec) - + api := NewTestTransportAPI() transport.SetAPI(api) - + return transport, api } @@ -286,19 +255,6 @@ func getAvailablePort(t testing.TB) string { return listener.Addr().String() } -func getAvailableUDPPort(t testing.TB) uint16 { - addr, err := net.ResolveUDPAddr("udp", "127.0.0.1:0") - if err != nil { - t.Fatalf("Failed to resolve UDP addr: %v", err) - } - conn, err := net.ListenUDP("udp", addr) - if err != nil { - t.Fatalf("Failed to listen UDP: %v", err) - } - defer conn.Close() - return uint16(conn.LocalAddr().(*net.UDPAddr).Port) -} - // waitForCondition waits for a condition to be true within a timeout func waitForCondition(condition func() bool, timeout time.Duration, message string) error { deadline := time.Now().Add(timeout) @@ -313,23 +269,23 @@ func waitForCondition(condition func() bool, timeout time.Duration, message stri // test wiring: register a trivial codec for a data packet id. func wireTestPacketCodec(enc *presentation.Encoder, dec *presentation.Decoder, id abstraction.PacketId) { - dataEnc := data.NewEncoder(binary.BigEndian) - dataDec := data.NewDecoder(binary.BigEndian) + dataEnc := data.NewEncoder(binary.BigEndian) + dataDec := data.NewDecoder(binary.BigEndian) - // Empty descriptor = no payload values, just the id header. - var desc data.Descriptor - dataEnc.SetDescriptor(id, desc) - dataDec.SetDescriptor(id, desc) + // Empty descriptor = no payload values, just the id header. + var desc data.Descriptor + dataEnc.SetDescriptor(id, desc) + dataDec.SetDescriptor(id, desc) - enc.SetPacketEncoder(id, dataEnc) - dec.SetPacketDecoder(id, dataDec) + enc.SetPacketEncoder(id, dataEnc) + dec.SetPacketDecoder(id, dataDec) } // Unit Tests func TestTransport_Creation(t *testing.T) { logger := zerolog.Nop() transport := NewTransport(logger) - + if transport == nil { t.Fatal("Transport should not be nil") } @@ -349,10 +305,10 @@ func TestTransport_Creation(t *testing.T) { func TestTransport_SetIdTarget(t *testing.T) { transport, _ := createTestTransport(t) - + transport.SetIdTarget(100, "TEST_BOARD") transport.SetIdTarget(200, "ANOTHER_BOARD") - + // Access the internal map to verify if target := transport.idToTarget[100]; target != abstraction.TransportTarget("TEST_BOARD") { t.Errorf("Expected TEST_BOARD, got %s", target) @@ -364,10 +320,10 @@ func TestTransport_SetIdTarget(t *testing.T) { func TestTransport_SetTargetIp(t *testing.T) { transport, _ := createTestTransport(t) - + transport.SetTargetIp("192.168.1.100", "TEST_BOARD") transport.SetTargetIp("192.168.1.101", "ANOTHER_BOARD") - + // Access the internal map to verify if target := transport.ipToTarget["192.168.1.100"]; target != abstraction.TransportTarget("TEST_BOARD") { t.Errorf("Expected TEST_BOARD, got %s", target) @@ -377,221 +333,54 @@ func TestTransport_SetTargetIp(t *testing.T) { } } -func TestWithTFTP(t *testing.T) { - tr := NewTransport(defaultLogger()) - tr.SetAPI(noopTransportAPI{}) - client := &tftp.Client{} - - out := tr.WithTFTP(client) - if out.tftp != client { - t.Fatalf("expected tftp client to be set") - } -} - -func TestTransportErrors(t *testing.T) { - tests := []struct { - err error - want string - }{ - {ErrUnrecognizedEvent{Event: PacketEvent}, "unrecognized event packet"}, - {ErrTargetAlreadyConnected{Target: "X"}, "X is already connected"}, - {ErrUnrecognizedId{Id: 7}, "could not find target for packet with id 7"}, - {ErrConnClosed{Target: "Y"}, "connection with Y is closed"}, - {ErrUnknownTarget{Remote: &net.TCPAddr{IP: net.ParseIP("1.2.3.4"), Port: 1234}}, "unknown target for 1.2.3.4:1234"}, - } - - for _, tt := range tests { - if got := tt.err.Error(); !strings.Contains(got, tt.want) { - t.Fatalf("expected %q to contain %q", got, tt.want) - } - } -} - -func TestMessages(t *testing.T) { - pm := NewPacketMessage(nil) - if pm.Event() != PacketEvent { - t.Fatalf("packet event mismatch") - } - - fr := bytes.NewBuffer(nil) - fwm := NewFileWriteMessage("a.bin", fr) - if fwm.Event() != FileWriteEvent || fwm.Filename() != "a.bin" { - t.Fatalf("file write message mismatch") - } - - fw := bytes.NewBuffer(nil) - frm := NewFileReadMessage("b.bin", fw) - if frm.Event() != FileReadEvent || frm.Filename() != "b.bin" { - t.Fatalf("file read message mismatch") - } -} - -func TestNotifications(t *testing.T) { - pn := NewPacketNotification(nil, "from", "to", zeroTime) - if pn.Event() != PacketEvent || pn.From != "from" || pn.To != "to" { - t.Fatalf("packet notification mismatch") - } - - en := NewErrorNotification(io.EOF) - if en.Event() != ErrorEvent || en.Err != io.EOF { - t.Fatalf("error notification mismatch") - } -} - -func TestSetpropagateFault(t *testing.T) { - tr := NewTransport(defaultLogger()) - tr.SetAPI(noopTransportAPI{}) - if tr.propagateFault { - t.Fatalf("expected propagateFault false by default") - } - tr.SetpropagateFault(true) - if !tr.propagateFault { - t.Fatalf("expected propagateFault true after setter") - } -} - -func TestTargetFromTCPConnKnown(t *testing.T) { - tr := NewTransport(defaultLogger()) - tr.SetAPI(noopTransportAPI{}) - tr.ipToTarget["127.0.0.1"] = "KNOWN" - pr, pw := net.Pipe() - defer pw.Close() - conn := &simpleConn{ - Conn: pr, - local: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 1}, - remote: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 2}, - } - - target, err := tr.targetFromTCPConn(conn) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if target != "KNOWN" { - t.Fatalf("expected target KNOWN, got %s", target) - } -} - -func TestTargetFromTCPConnUnknown(t *testing.T) { - tr := NewTransport(defaultLogger()) - tr.SetAPI(noopTransportAPI{}) - pr, pw := net.Pipe() - defer pw.Close() - conn := &simpleConn{ - Conn: pr, - local: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 1}, - remote: &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 2}, - } +func TestTransport_InvalidInputs(t *testing.T) { + transport, _ := createTestTransport(t) - _, err := tr.targetFromTCPConn(conn) + // Test invalid ID input + err := transport.SetIdTarget(0, "") if err == nil { - t.Fatalf("expected error for unknown target") - } - if _, ok := err.(ErrUnknownTarget); !ok { - t.Fatalf("expected ErrUnknownTarget, got %T", err) - } -} - -func TestRejectIfConnectedTCPConn(t *testing.T) { - tr := NewTransport(defaultLogger()) - tr.SetAPI(noopTransportAPI{}) - tr.connections["X"] = &simpleConn{} - - // new conn to reject - pr, pw := net.Pipe() - defer pw.Close() - conn := &simpleConn{ - Conn: pr, - local: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 1}, - remote: &net.TCPAddr{IP: net.ParseIP("127.0.0.2"), Port: 2}, - } - - err := tr.rejectIfConnectedTCPConn("X", conn, defaultLogger()) - if _, ok := err.(ErrTargetAlreadyConnected); !ok { - t.Fatalf("expected ErrTargetAlreadyConnected, got %v", err) - } - // conn should be closed - if _, werr := conn.Write([]byte("test")); werr == nil { - t.Fatalf("expected write to fail on closed conn") + t.Errorf("Expected error for invalid ID input, got nil") } -} -func TestHandlePacketEvent_TargetNotConnected(t *testing.T) { - tr, _ := createTestTransport(t) - tr.SetpropagateFault(false) - tr.idToTarget[42] = "TARGET" - // encoder/decoder wired only for id 100; id 42 will cause ErrUnexpectedId in encoder - pkt := data.NewPacket(42) - err := tr.handlePacketEvent(NewPacketMessage(pkt)) + // Test invalid IP input + err = transport.SetTargetIp("", "") if err == nil { - t.Fatalf("expected error for missing encoder/connection") + t.Errorf("Expected error for invalid IP input, got nil") } } -func TestReplicateFaultBroadcast(t *testing.T) { - tr, api := createTestTransport(t) - tr.SetpropagateFault(true) - // create a connection to receive broadcast - c1, c2 := net.Pipe() - tr.connectionsMx.Lock() - tr.connections["TARGET"] = c1 - tr.connectionsMx.Unlock() - defer c1.Close() - defer c2.Close() - - go tr.replicateFault(data.NewPacket(0), tr.logger) - - buf := make([]byte, 2) - if _, err := io.ReadFull(c2, buf); err != nil { - t.Fatalf("expected broadcast data, got err %v", err) - } - // ensure no error notifications - if len(api.GetNotifications()) != 0 { - t.Fatalf("expected no notifications during replicateFault") - } -} - -func TestHandleUDPPacket_Success(t *testing.T) { - tr, api := createTestTransport(t) - tr.SetpropagateFault(false) +func TestTransport_RemoveTargets(t *testing.T) { + transport, _ := createTestTransport(t) - pkt := data.NewPacket(100) - pkt.SetTimestamp(time.Unix(0, 0)) - buf, err := tr.encoder.Encode(pkt) - if err != nil { - t.Fatalf("encode failed: %v", err) - } + // Add entries + transport.SetIdTarget(100, "TEST_BOARD") + transport.SetTargetIp("192.168.1.100", "TEST_BOARD") - payload := append([]byte(nil), buf.Bytes()...) - tr.encoder.ReleaseBuffer(buf) + // Remove entries + delete(transport.idToTarget, 100) + delete(transport.ipToTarget, "192.168.1.100") - udpPkt := udp.Packet{ - SourceIP: net.ParseIP("127.0.0.1"), - SourcePort: 9999, - DestIP: net.ParseIP("127.0.0.1"), - DestPort: 9998, - Payload: payload, - Timestamp: time.Unix(0, 0), + // Verify removal + if _, exists := transport.idToTarget[100]; exists { + t.Errorf("Expected ID 100 to be removed, but it still exists") } - - tr.handleUDPPacket(udpPkt) - - if len(api.GetNotifications()) == 0 { - t.Fatalf("expected notification after UDP packet") + if _, exists := transport.ipToTarget["192.168.1.100"]; exists { + t.Errorf("Expected IP 192.168.1.100 to be removed, but it still exists") } } // Integration Tests func TestTransport_ClientServerConnection(t *testing.T) { transport, api := createTestTransport(t) - + // Setup board configuration boardIP := "127.0.0.1" boardPort := getAvailablePort(t) target := abstraction.TransportTarget("TEST_BOARD") - + transport.SetTargetIp(boardIP, target) transport.SetIdTarget(100, target) - + // Create and start mock board server mockBoard := NewMockBoardServer(boardPort) err := mockBoard.Start() @@ -599,23 +388,23 @@ func TestTransport_ClientServerConnection(t *testing.T) { t.Fatalf("Failed to start mock board: %v", err) } defer mockBoard.Stop() - + // Configure client clientAddr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("Failed to resolve client address: %v", err) } - + clientConfig := tcp.NewClientConfig(clientAddr) clientConfig.TryReconnect = false // Don't retry for this test - + // Start client connection in goroutine clientDone := make(chan error, 1) go func() { err := transport.HandleClient(clientConfig, boardPort) clientDone <- err }() - + // Ensure cleanup defer func() { mockBoard.Stop() @@ -626,7 +415,7 @@ func TestTransport_ClientServerConnection(t *testing.T) { // Client should exit when board stops } }() - + // Wait for connection err = waitForCondition(func() bool { return mockBoard.GetConnectionCount() > 0 @@ -634,7 +423,7 @@ func TestTransport_ClientServerConnection(t *testing.T) { if err != nil { t.Fatal(err) } - + // Verify connection update was sent err = waitForCondition(func() bool { updates := api.GetConnectionUpdates() @@ -643,10 +432,10 @@ func TestTransport_ClientServerConnection(t *testing.T) { if err != nil { t.Fatal(err) } - + // Stop the board to trigger disconnection mockBoard.Stop() - + // Wait for client to detect disconnection select { case err := <-clientDone: @@ -657,7 +446,7 @@ func TestTransport_ClientServerConnection(t *testing.T) { case <-time.After(2 * time.Second): t.Fatal("Client should have detected disconnection") } - + // Verify disconnection update err = waitForCondition(func() bool { updates := api.GetConnectionUpdates() @@ -670,16 +459,16 @@ func TestTransport_ClientServerConnection(t *testing.T) { func TestTransport_PacketSending(t *testing.T) { transport, api := createTestTransport(t) - + // Setup boardIP := "127.0.0.1" boardPort := getAvailablePort(t) target := abstraction.TransportTarget("TEST_BOARD") packetID := abstraction.PacketId(100) - + transport.SetTargetIp(boardIP, target) transport.SetIdTarget(packetID, target) - + // Create mock board mockBoard := NewMockBoardServer(boardPort) err := mockBoard.Start() @@ -687,18 +476,18 @@ func TestTransport_PacketSending(t *testing.T) { t.Fatalf("Failed to start mock board: %v", err) } defer mockBoard.Stop() - + // Start client clientAddr, _ := net.ResolveTCPAddr("tcp", "127.0.0.1:0") clientConfig := tcp.NewClientConfig(clientAddr) clientConfig.TryReconnect = false - + clientDone := make(chan struct{}) go func() { defer close(clientDone) transport.HandleClient(clientConfig, boardPort) }() - + // Ensure cleanup defer func() { mockBoard.Stop() @@ -707,25 +496,25 @@ func TestTransport_PacketSending(t *testing.T) { case <-time.After(1 * time.Second): } }() - + // Wait for connection err = waitForCondition(func() bool { - updates := api.GetConnectionUpdates() - return len(updates) > 0 && updates[len(updates)-1].Target == target && updates[len(updates)-1].IsConnected + updates := api.GetConnectionUpdates() + return len(updates) > 0 && updates[len(updates)-1].Target == target && updates[len(updates)-1].IsConnected }, 2*time.Second, "Should establish connection") if err != nil { t.Fatal(err) } - + // Create and send packet testPacket := data.NewPacket(packetID) testPacket.SetTimestamp(time.Now()) - + err = transport.SendMessage(NewPacketMessage(testPacket)) if err != nil { t.Fatalf("Failed to send packet: %v", err) } - + // Verify packet was received by board err = waitForCondition(func() bool { packets := mockBoard.GetReceivedPackets() @@ -734,7 +523,7 @@ func TestTransport_PacketSending(t *testing.T) { if err != nil { t.Fatal(err) } - + // Verify no error notifications notifications := api.GetNotifications() for _, notification := range notifications { @@ -746,16 +535,16 @@ func TestTransport_PacketSending(t *testing.T) { func TestTransport_UnknownTarget(t *testing.T) { transport, api := createTestTransport(t) - + // Try to send packet to unknown target unknownPacket := data.NewPacket(999) // Unknown packet ID unknownPacket.SetTimestamp(time.Now()) - + err := transport.SendMessage(NewPacketMessage(unknownPacket)) if err == nil { t.Fatal("Expected error when sending to unknown target") } - + // Should be ErrUnrecognizedId var unrecognizedErr ErrUnrecognizedId if !ErrorAs(err, &unrecognizedErr) { @@ -763,7 +552,7 @@ func TestTransport_UnknownTarget(t *testing.T) { } else if unrecognizedErr.Id != abstraction.PacketId(999) { t.Errorf("Expected packet ID 999, got %d", unrecognizedErr.Id) } - + // Verify error notification err = waitForCondition(func() bool { notifications := api.GetNotifications() @@ -780,43 +569,43 @@ func TestTransport_UnknownTarget(t *testing.T) { func TestTransport_ReconnectionBehavior(t *testing.T) { transport, api := createTestTransport(t) - + // Setup boardIP := "127.0.0.1" boardPort := getAvailablePort(t) target := abstraction.TransportTarget("RECONNECT_BOARD") - + transport.SetTargetIp(boardIP, target) transport.SetIdTarget(100, target) - + // Create mock board mockBoard := NewMockBoardServer(boardPort) err := mockBoard.Start() if err != nil { t.Fatalf("Failed to start mock board: %v", err) } - + // Configure client with fast reconnection for testing clientAddr, _ := net.ResolveTCPAddr("tcp", "127.0.0.1:0") clientConfig := tcp.NewClientConfig(clientAddr) clientConfig.TryReconnect = true clientConfig.MaxConnectionRetries = 0 // Infinite retries clientConfig.ConnectionBackoffFunction = tcp.NewExponentialBackoff( - 10*time.Millisecond, // Fast for testing + 10*time.Millisecond, // Fast for testing 1.5, 100*time.Millisecond, ) - + // Start client with proper cleanup ctx, cancel := context.WithCancel(context.Background()) clientConfig.Context = ctx - + clientDone := make(chan struct{}) go func() { defer close(clientDone) transport.HandleClient(clientConfig, boardPort) }() - + // Ensure cleanup happens defer func() { cancel() @@ -828,7 +617,7 @@ func TestTransport_ReconnectionBehavior(t *testing.T) { t.Log("Warning: client goroutine did not finish within timeout") } }() - + // Wait for initial connection err = waitForCondition(func() bool { return mockBoard.GetConnectionCount() > 0 @@ -836,7 +625,7 @@ func TestTransport_ReconnectionBehavior(t *testing.T) { if err != nil { t.Fatal(err) } - + // Verify connection update err = waitForCondition(func() bool { updates := api.GetConnectionUpdates() @@ -845,10 +634,10 @@ func TestTransport_ReconnectionBehavior(t *testing.T) { if err != nil { t.Fatal(err) } - + // Simulate board restart mockBoard.Stop() - + // Wait for disconnection detection err = waitForCondition(func() bool { updates := api.GetConnectionUpdates() @@ -862,14 +651,14 @@ func TestTransport_ReconnectionBehavior(t *testing.T) { if err != nil { t.Fatal(err) } - + // Restart board mockBoard = NewMockBoardServer(boardPort) err = mockBoard.Start() if err != nil { t.Fatalf("Failed to restart mock board: %v", err) } - + // Wait for reconnection err = waitForCondition(func() bool { return mockBoard.GetConnectionCount() > 0 @@ -877,7 +666,7 @@ func TestTransport_ReconnectionBehavior(t *testing.T) { if err != nil { t.Fatal(err) } - + // Verify reconnection update err = waitForCondition(func() bool { updates := api.GetConnectionUpdates() @@ -902,169 +691,6 @@ func TestTransport_ReconnectionBehavior(t *testing.T) { } } -func TestHandleServer_AcceptsAndDispatches(t *testing.T) { - tr, api := createTestTransport(t) - target := abstraction.TransportTarget("SERVER_TARGET") - tr.SetTargetIp("127.0.0.1", target) - tr.SetIdTarget(100, target) - - local := getAvailablePort(t) - cfg := tcp.NewServerConfig() - ctx, cancel := context.WithCancel(context.Background()) - cfg.Context = ctx - defer cancel() - - done := make(chan struct{}) - go func() { - _ = tr.HandleServer(cfg, local) - close(done) - }() - - var conn net.Conn - var err error - deadline := time.Now().Add(500 * time.Millisecond) - for time.Now().Before(deadline) { - conn, err = net.Dial("tcp", local) - if err == nil { - break - } - time.Sleep(20 * time.Millisecond) - } - if conn == nil { - t.Fatalf("failed to dial server: %v", err) - } - defer conn.Close() - - packet := data.NewPacket(100) - packet.SetTimestamp(time.Unix(0, 0)) - buf, err := tr.encoder.Encode(packet) - if err != nil { - t.Fatalf("encode failed: %v", err) - } - defer tr.encoder.ReleaseBuffer(buf) - - if _, err := conn.Write(buf.Bytes()); err != nil { - t.Fatalf("failed to write packet: %v", err) - } - - if err := waitForCondition(func() bool { - return len(api.GetNotifications()) > 0 - }, 2*time.Second, "Should receive notification from server connection"); err != nil { - t.Fatal(err) - } - - cancel() - select { - case <-done: - case <-time.After(500 * time.Millisecond): - } -} - -func TestHandleUDPServer_Dispatches(t *testing.T) { - tr, api := createTestTransport(t) - tr.SetpropagateFault(false) - - port := getAvailableUDPPort(t) - logger := zerolog.Nop() - server := udp.NewServer("127.0.0.1", port, &logger) - if err := server.Start(); err != nil { - t.Fatalf("failed to start UDP server: %v", err) - } - defer server.Stop() - - go tr.HandleUDPServer(server) - - packet := data.NewPacket(100) - packet.SetTimestamp(time.Unix(0, 0)) - buf, err := tr.encoder.Encode(packet) - if err != nil { - t.Fatalf("encode failed: %v", err) - } - defer tr.encoder.ReleaseBuffer(buf) - - conn, err := net.DialUDP("udp", nil, &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: int(port)}) - if err != nil { - t.Fatalf("failed to dial UDP server: %v", err) - } - defer conn.Close() - - if _, err := conn.Write(buf.Bytes()); err != nil { - t.Fatalf("failed to send UDP packet: %v", err) - } - - if err := waitForCondition(func() bool { - return len(api.GetNotifications()) > 0 - }, 2*time.Second, "Should receive notification from UDP server"); err != nil { - t.Fatal(err) - } -} - -func TestHandleSniffer_Dispatches(t *testing.T) { - tr, api := createTestTransport(t) - - // empty pcap (header only) to drive HandleSniffer through EOF path - tmp, err := os.CreateTemp("", "sniffer*.pcap") - if err != nil { - t.Fatalf("failed to create temp file: %v", err) - } - writer := pcapgo.NewWriter(tmp) - if err := writer.WriteFileHeader(65535, layers.LinkTypeEthernet); err != nil { - t.Fatalf("write header failed: %v", err) - } - tmp.Close() - - handle, err := pcap.OpenOffline(tmp.Name()) - if err != nil { - t.Fatalf("failed to open pcap: %v", err) - } - sn := sniffer.New(handle, nil, defaultLogger()) - - done := make(chan struct{}) - go func() { - tr.HandleSniffer(sn) - close(done) - }() - - select { - case <-done: - case <-time.After(2 * time.Second): - t.Fatalf("HandleSniffer did not return on EOF") - } - - // No notifications expected; just ensure no panic/block. - _ = api -} - -func TestHandleConversation_DispatchesAndStopsOnError(t *testing.T) { - tr, api := createTestTransport(t) - - pkt := data.NewPacket(100) - pkt.SetTimestamp(time.Unix(0, 0)) - buf, err := tr.encoder.Encode(pkt) - if err != nil { - t.Fatalf("encode failed: %v", err) - } - defer tr.encoder.ReleaseBuffer(buf) - - socket := network.Socket{ - SrcIP: "127.0.0.1", - SrcPort: 8000, - DstIP: "127.0.0.1", - DstPort: 8001, - } - - reader := bytes.NewReader(buf.Bytes()) - tr.handleConversation(socket, reader) - - if err := waitForCondition(func() bool { return len(api.GetNotifications()) >= 1 }, time.Second, "packet notification"); err != nil { - t.Fatal(err) - } - // After the first packet, DecodeNext will hit EOF and SendFault will result in an error notification. - if err := waitForCondition(func() bool { return len(api.GetNotifications()) >= 2 }, 2*time.Second, "error notification"); err != nil { - t.Fatal(err) - } -} - // Helper function to mimic errors.As behavior func ErrorAs(err error, target interface{}) bool { switch target := target.(type) { @@ -1080,4 +706,4 @@ func ErrorAs(err error, target interface{}) bool { } } return false -} +} \ No newline at end of file diff --git a/backend/pkg/vehicle/constructor.go b/backend/pkg/vehicle/constructor.go index 8468195f9..6dab58618 100644 --- a/backend/pkg/vehicle/constructor.go +++ b/backend/pkg/vehicle/constructor.go @@ -75,3 +75,8 @@ func (vehicle *Vehicle) SetIpToBoardId(ipToBoardId map[string]abstraction.BoardI vehicle.ipToBoardId = ipToBoardId vehicle.trace.Info().Msg("set ip to board id") } + +func (vehicle *Vehicle) SetBlcuId(id abstraction.BoardId) { + vehicle.BlcuId = id + vehicle.trace.Info().Uint16("blcu_id", uint16(id)).Msg("set blcu id") +} diff --git a/backend/pkg/vehicle/notification.go b/backend/pkg/vehicle/notification.go index 4bf0d9d05..8a049e606 100644 --- a/backend/pkg/vehicle/notification.go +++ b/backend/pkg/vehicle/notification.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" + "github.com/HyperloopUPV-H8/h9-backend/pkg/boards" data_topic "github.com/HyperloopUPV-H8/h9-backend/pkg/broker/topics/data" message_topic "github.com/HyperloopUPV-H8/h9-backend/pkg/broker/topics/message" order_topic "github.com/HyperloopUPV-H8/h9-backend/pkg/broker/topics/order" @@ -14,7 +15,7 @@ import ( protection_logger "github.com/HyperloopUPV-H8/h9-backend/pkg/logger/protection" state_logger "github.com/HyperloopUPV-H8/h9-backend/pkg/logger/state" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport" - + blcu_packet "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/packet/blcu" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/packet/data" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/packet/order" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/packet/protection" @@ -129,7 +130,12 @@ func (vehicle *Vehicle) handlePacketNotification(notification transport.PacketNo vehicle.trace.Error().Stack().Err(err).Msg("remove state orders") return errors.Join(fmt.Errorf("remove state orders (state orders from %s to %s)", notification.From, notification.To), err) } - + case *blcu_packet.Ack: + vehicle.boards[vehicle.BlcuId].Notify(abstraction.BoardNotification( + &boards.AckNotification{ + ID: boards.AckId, + }, + )) } return nil } diff --git a/backend/pkg/vehicle/vehicle.go b/backend/pkg/vehicle/vehicle.go index 6e51cbb24..e682f29f4 100644 --- a/backend/pkg/vehicle/vehicle.go +++ b/backend/pkg/vehicle/vehicle.go @@ -5,10 +5,12 @@ import ( "fmt" "os" + "github.com/HyperloopUPV-H8/h9-backend/pkg/boards" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/packet/protection" "github.com/HyperloopUPV-H8/h9-backend/internal/update_factory" "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" + blcu_topic "github.com/HyperloopUPV-H8/h9-backend/pkg/broker/topics/blcu" connection_topic "github.com/HyperloopUPV-H8/h9-backend/pkg/broker/topics/connection" logger_topic "github.com/HyperloopUPV-H8/h9-backend/pkg/broker/topics/logger" message_topic "github.com/HyperloopUPV-H8/h9-backend/pkg/broker/topics/message" @@ -93,6 +95,47 @@ func (vehicle *Vehicle) UserPush(push abstraction.BrokerPush) error { status.Fulfill(status.Enable()) } + case "blcu/downloadRequest": + download := push.(*blcu_topic.DownloadRequest) + + if board, exists := vehicle.boards[vehicle.BlcuId]; exists { + board.Notify(abstraction.BoardNotification( + &boards.DownloadEvent{ + BoardEvent: boards.DownloadEventId, + BoardID: vehicle.BlcuId, + Board: download.Board, + }, + )) + } else { + fmt.Fprintf(os.Stderr, "BLCU board not registered\n") + } + + case "blcu/uploadRequest": + // Handle both UploadRequest and UploadRequestInternal + var uploadEvent *boards.UploadEvent + switch u := push.(type) { + case *blcu_topic.UploadRequestInternal: + uploadEvent = &boards.UploadEvent{ + BoardEvent: boards.UploadEventId, + Board: u.Board, + Data: u.Data, + Length: len(u.Data), + } + case *blcu_topic.UploadRequest: + // This shouldn't happen as the handler should convert to Internal + fmt.Fprintf(os.Stderr, "received raw UploadRequest, expected UploadRequestInternal\n") + return nil + default: + fmt.Fprintf(os.Stderr, "unknown upload type: %T\n", push) + return nil + } + + if board, exists := vehicle.boards[vehicle.BlcuId]; exists { + board.Notify(abstraction.BoardNotification(uploadEvent)) + } else { + fmt.Fprintf(os.Stderr, "BLCU board not registered\n") + } + default: fmt.Printf("unknow topic %s\n", push.Topic()) } diff --git a/backend/pkg/websocket/pool.go b/backend/pkg/websocket/pool.go index 41736015e..6d677fe4e 100644 --- a/backend/pkg/websocket/pool.go +++ b/backend/pkg/websocket/pool.go @@ -13,12 +13,12 @@ type ClientId uuid.UUID type messageCallback = func(ClientId, *Message) type Pool struct { - clientMx *sync.Mutex - clients map[ClientId]*Client - connections <-chan *Client - onMessage messageCallback - onDisconnect func(count int) - logger zerolog.Logger + clientMx *sync.Mutex + clients map[ClientId]*Client + connections <-chan *Client + onMessage messageCallback + + logger zerolog.Logger } func NewPool(connections <-chan *Client, baseLogger zerolog.Logger) *Pool { @@ -31,12 +31,12 @@ func NewPool(connections <-chan *Client, baseLogger zerolog.Logger) *Pool { }) handler := &Pool{ - clientMx: &sync.Mutex{}, - clients: make(map[ClientId]*Client), - connections: connections, - onMessage: func(ClientId, *Message) {}, - onDisconnect: func(count int) {}, - logger: logger, + clientMx: &sync.Mutex{}, + clients: make(map[ClientId]*Client), + connections: connections, + onMessage: func(ClientId, *Message) {}, + + logger: logger, } go handler.listen() @@ -111,11 +111,6 @@ func (pool *Pool) Broadcast(message Message) { } } -func (pool *Pool) SetOnDisconnect(onDisconnect func(count int)) { - pool.logger.Trace().Msg("set on disconnect") - pool.onDisconnect = onDisconnect -} - func (pool *Pool) Disconnect(id ClientId, code int, reason string) error { clientLogger := pool.logger.With().Str("id", uuid.UUID(id).String()).Logger() @@ -134,16 +129,10 @@ func (pool *Pool) Disconnect(id ClientId, code int, reason string) error { func (pool *Pool) onClose(id ClientId) func() { return func() { pool.clientMx.Lock() + defer pool.clientMx.Unlock() pool.logger.Debug().Str("id", uuid.UUID(id).String()).Msg("close") delete(pool.clients, id) - - count := len(pool.clients) - pool.clientMx.Unlock() - - if pool.onDisconnect != nil { - pool.onDisconnect(count) - } } } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 000000000..cd795342e --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,46 @@ +version: '3.8' + +services: + # Backend service + backend: + build: + context: ./backend + dockerfile: Dockerfile.dev + volumes: + - ./backend:/app + - ./adj:/app/adj + ports: + - "8080:8080" + environment: + - CGO_ENABLED=1 + command: sh -c "cd cmd && go run ." + + # Ethernet View + ethernet-view: + image: node:18-alpine + working_dir: /app + volumes: + - ./ethernet-view:/app + - ./common-front:/common-front + ports: + - "5174:5174" + command: sh -c "npm install && npm run dev -- --host" + + # Control Station + control-station: + image: node:18-alpine + working_dir: /app + volumes: + - ./control-station:/app + - ./common-front:/common-front + ports: + - "5173:5173" + command: sh -c "npm install && npm run dev -- --host" + + # Common front (for building) + common-front: + image: node:18-alpine + working_dir: /app + volumes: + - ./common-front:/app + command: sh -c "npm install && npm run build && npm run dev" \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..537a8aad0 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,100 @@ +# Hyperloop UPV Control Station Documentation + +Welcome to the comprehensive documentation for the Hyperloop UPV Control Station - a real-time monitoring and control system for hyperloop pod operations. + +## 🚀 Quick Navigation + +### For New Users +1. **[Getting Started Guide](guides/getting-started.md)** - First steps with the Control Station +2. **[System Overview](architecture/README.md)** - Understand the architecture +3. **[Complete Architecture Guide](../CONTROL_STATION_COMPLETE_ARCHITECTURE.md)** - Deep dive into the system + +### For Developers +1. **[Development Setup](development/DEVELOPMENT.md)** - Set up your environment +2. **[Packet Flow Reference](architecture/packet-flow-reference.md)** - Understand data flow +3. **[Backend Architecture](architecture/backend.md)** - Backend implementation details +4. **[Issues and Improvements](architecture/issues-and-improvements.md)** - Known issues and roadmap + +### For Operators +1. **[Configuration Guide](guides/configuration.md)** - System configuration +2. **[Deployment Guide](guides/deployment.md)** - Production deployment +3. **[Common Issues](troubleshooting/common-issues.md)** - Troubleshooting guide + +## 📚 Complete Documentation Index + +### 🏗️ Architecture & Design +- **[System Overview](architecture/README.md)** - High-level architecture overview +- **[Complete Architecture Guide](../CONTROL_STATION_COMPLETE_ARCHITECTURE.md)** - Comprehensive system documentation with full packet flow +- **[Backend Architecture](architecture/backend.md)** - Go backend design and implementation +- **[Frontend Architecture](architecture/frontend.md)** - React frontend structure +- **[Packet Flow Reference](architecture/packet-flow-reference.md)** - Quick reference for data flow +- **[Communication Protocols](architecture/protocols.md)** - Network protocol overview +- **[Issues and Improvements](architecture/issues-and-improvements.md)** - Technical debt and roadmap + +### 🛠️ Development +- **[Development Setup](development/DEVELOPMENT.md)** - Complete development environment guide +- **[Cross-Platform Scripts](development/CROSS_PLATFORM_DEV_SUMMARY.md)** - Development scripts documentation +- **[Scripts Reference](development/scripts.md)** - All available development scripts + +### 📖 User Guides +- **[Getting Started](guides/getting-started.md)** - Quick start for new users +- **[Configuration](guides/configuration.md)** - config.toml and ADJ specifications +- **[Deployment](guides/deployment.md)** - Production deployment instructions +- **[Testing](guides/testing.md)** - Testing strategies and tools + +### 🔧 Troubleshooting +- **[Common Issues](troubleshooting/common-issues.md)** - Frequently encountered problems +- **[BLCU Fix Summary](troubleshooting/BLCU_FIX_SUMMARY.md)** - Bootloader troubleshooting +- **[Platform-Specific Issues](troubleshooting/platform-issues.md)** - OS-specific problems + +## 🚀 Quick Start + +New to the project? Start here: + +1. **[Getting Started Guide](guides/getting-started.md)** - Overview and first steps +2. **[Development Setup](development/DEVELOPMENT.md)** - Set up your development environment +3. **[Architecture Overview](architecture/README.md)** - Understand the system design + +## 📋 Additional Resources + +### Root Level Documentation +- **[README.md](../README.md)** - Project overview and quick setup +- **[CONTRIBUTING.md](../CONTRIBUTING.md)** - How to contribute to the project +- **[CLAUDE.md](../CLAUDE.md)** - AI assistant instructions for code development + +### Component-Specific Documentation +- **[Common Frontend](../common-front/README.md)** - Shared React component library +- **[Backend Captures](../backend/captures/README.md)** - Network packet capture samples + +### GitHub Templates +- **[Bug Reports](../.github/ISSUE_TEMPLATE/bug.md)** - Bug report template +- **[Feature Requests](../.github/ISSUE_TEMPLATE/feature.md)** - Feature request template +- **[Task Templates](../.github/ISSUE_TEMPLATE/task.md)** - Task template + +## 🔄 Documentation Updates + +This documentation is actively maintained. If you find errors or have suggestions: + +1. Check existing [issues](https://github.com/HyperloopUPV-H8/h9-backend/issues) +2. Create a new issue using the appropriate template +3. Submit a pull request with improvements + +## 📝 Contributing to Documentation + +When adding new documentation: + +1. **Development docs** → `docs/development/` +2. **Architecture docs** → `docs/architecture/` +3. **User guides** → `docs/guides/` +4. **Troubleshooting** → `docs/troubleshooting/` +5. **Component-specific** → Keep in respective component directories + +Follow the existing markdown style and include: +- Clear headings and structure +- Code examples where applicable +- Cross-references to related documentation +- Platform-specific notes when relevant + +--- + +*Last updated: 2025-06-03* diff --git a/docs/architecture/README.md b/docs/architecture/README.md new file mode 100644 index 000000000..3bc3ebb6c --- /dev/null +++ b/docs/architecture/README.md @@ -0,0 +1,88 @@ +# System Architecture + +The Hyperloop UPV Control Station is a real-time monitoring and control system for pod operations. + +## Quick Links +- 📖 **[Complete Architecture Guide](../../CONTROL_STATION_COMPLETE_ARCHITECTURE.md)** - Comprehensive system documentation +- 🔄 **[Packet Flow Reference](packet-flow-reference.md)** - Quick reference for data flow +- 📡 **[Communication Protocols](protocols.md)** - Network protocol specifications +- 🐛 **[Known Issues](issues-and-improvements.md)** - Current limitations and roadmap + +## Overview + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Pod Boards │◄──►│ Backend (Go) │◄──►│ Frontend (React)│ +│ │ │ │ │ │ +│ • Sensors │ │ • TCP/UDP │ │ • Control UI │ +│ • Actuators │ │ • Processing │ │ • Monitoring │ +│ • Controllers │ │ • WebSocket │ │ • Logging │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + ▲ │ ▲ + │ ▼ │ + │ ┌─────────────────┐ │ + └────────────│ ADJ Config │──────────────┘ + │ (JSON specs) │ + └─────────────────┘ +``` + +## Core Components + +### Backend (Go) +High-performance server managing real-time communication and data processing. +- **Location**: [`backend/`](../../backend) +- **Key Features**: Concurrent packet processing, automatic reconnection, sub-10ms response time +- **Documentation**: [Backend Architecture](backend.md) + +### Frontend (React/TypeScript) +Modern web interfaces for system monitoring and control. +- **Locations**: [`control-station/`](../../control-station), [`ethernet-view/`](../../ethernet-view) +- **Key Features**: Real-time updates, interactive controls, data visualization +- **Documentation**: [Frontend Architecture](frontend.md) + +### ADJ System +JSON-based configuration defining all communication specifications. +- **Location**: [`adj/`](https://github.com/HyperloopUPV-H8/adj) (external repository) +- **Purpose**: Board definitions, packet structures, unit conversions +- **Documentation**: [ADJ Specification](../../backend/internal/adj/README.md) + +## Key Capabilities + +- **Real-time Performance**: 100+ Mbps data processing, <10ms fault detection +- **Modular Design**: Board-agnostic architecture using ADJ specifications +- **Fault Tolerance**: Automatic reconnection, graceful degradation +- **Scalability**: Supports 10+ concurrent board connections + +## Technology Stack + +| Component | Technology | Purpose | +|-----------|------------|---------| +| Backend | Go 1.21+ | High-performance networking and concurrency | +| Frontend | React 18 + TypeScript | Type-safe UI development | +| Communication | WebSocket | Real-time bidirectional updates | +| Configuration | JSON (ADJ) | Flexible system specification | +| Networking | TCP/UDP/TFTP | Reliable and fast data transfer | + +## Documentation Index + +### Architecture Details +- [Backend Architecture](backend.md) - Go server design and implementation +- [Frontend Architecture](frontend.md) - React application structure +- [Binary Protocol](binary-protocol.md) - Wire protocol specification +- [WebSocket API](websocket-api.md) - Frontend-backend communication + +### Development Resources +- [Getting Started](../guides/getting-started.md) - New developer guide +- [Development Setup](../development/DEVELOPMENT.md) - Environment configuration +- [Troubleshooting](../troubleshooting/common-issues.md) - Common problems + +## Next Steps + +- **New to the project?** Start with the [Getting Started Guide](../guides/getting-started.md) +- **Understanding data flow?** Read the [Complete Architecture Guide](../../CONTROL_STATION_COMPLETE_ARCHITECTURE.md) +- **Debugging issues?** Check [Troubleshooting](../troubleshooting/common-issues.md) +- **Contributing?** Review [Known Issues](issues-and-improvements.md) for areas needing help + +--- + +*For the complete technical deep-dive, see the [Control Station Complete Architecture](../../CONTROL_STATION_COMPLETE_ARCHITECTURE.md) document.* \ No newline at end of file diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md new file mode 100644 index 000000000..f3cd74400 --- /dev/null +++ b/docs/architecture/backend.md @@ -0,0 +1,346 @@ +# Backend Architecture + +The backend is a high-performance Go server managing real-time communication between pod boards and the control station frontend. + +> **Note**: For complete system architecture and packet flow, see the [Control Station Complete Architecture](../../CONTROL_STATION_COMPLETE_ARCHITECTURE.md) document. + +## Design Principles + +- **Real-time Performance**: Sub-10ms fault detection with concurrent processing +- **Type Safety**: Strongly typed packet definitions from ADJ specifications +- **Fault Tolerance**: Automatic reconnection and graceful degradation +- **Modular Architecture**: Clear separation of concerns across packages + +## Package Structure + +``` +backend/ +├── cmd/ +│ └── main.go # Entry point, initialization +├── internal/ +│ ├── adj/ # ADJ parser and validator +│ ├── pod_data/ # Packet structure definitions +│ ├── update_factory/ # Update message creation +│ └── vehicle/ # Vehicle state management +└── pkg/ + ├── abstraction/ # Core interfaces + ├── boards/ # Board-specific logic (BLCU) + ├── broker/ # Message distribution + ├── transport/ # Network communication + ├── vehicle/ # Board coordination + └── websocket/ # Frontend connections +``` + +## Core Components + +### Transport Layer (`pkg/transport/`) + +Manages all network I/O with pluggable handlers: + +```go +// Transport coordinates all network communication +type Transport struct { + decoder *presentation.Decoder + encoder *presentation.Encoder + connections map[TransportTarget]net.Conn + mu sync.RWMutex +} +``` + +**Key Features**: +- Thread-safe connection pooling +- Automatic reconnection with exponential backoff +- Concurrent packet processing +- Special handling for fault propagation (packet ID 0) + +### Presentation Layer + +Handles binary protocol encoding/decoding: + +```go +// Packet structure (little-endian) +type Packet struct { + ID uint16 // 2 bytes + Payload []byte // Variable length +} +``` + +**Decoding Process**: +1. Read packet ID (2 bytes) +2. Look up decoder by ID +3. Parse payload based on ADJ descriptor +4. Apply unit conversions +5. Return typed packet object + +### Message Broker + +Central hub for internal message routing: + +```go +// Topic-based publish/subscribe +type Broker struct { + topics map[string]Topic + pool *WebSocketPool +} +``` + +**Topic Hierarchy**: +- `data/update` - Real-time measurements +- `connection/update` - Board status +- `order/send` - Command dispatch +- `protection/alert` - Safety notifications +- `logger/*` - Logging control +- `blcu/*` - Bootloader operations + +### Vehicle Manager + +Coordinates board interactions and state: + +```go +type Vehicle struct { + broker *Broker + logger *Logger + transport *Transport + boards map[BoardId]Board + updateFactory *UpdateFactory +} +``` + +**Responsibilities**: +- Order validation and execution +- State management +- Board registry +- Error propagation + +## Concurrency Model + +### Goroutine Architecture + +```go +// Per-connection handler +go func(conn net.Conn) { + defer conn.Close() + for { + packet := readPacket(conn) + packetChan <- packet + } +}() + +// Packet processor +go func() { + for packet := range packetChan { + processPacket(packet) + } +}() +``` + +### Channel Usage + +- **Packet Distribution**: Buffered channels for flow control +- **Error Handling**: Dedicated error channel +- **Shutdown Coordination**: Context cancellation + +## Performance Optimizations + +### Network Tuning + +```go +// TCP optimizations +conn.SetNoDelay(true) // Disable Nagle +conn.SetKeepAlive(true) // Enable keep-alive +conn.SetKeepAlivePeriod(1*time.Second) +``` + +### Memory Management + +- **Object Pooling**: Reused packet buffers +- **Bounded Queues**: Prevent memory exhaustion +- **Zero-Copy Operations**: Minimize allocations + +### Profiling Support + +```bash +# CPU profiling +./backend -cpuprofile=cpu.prof + +# Memory profiling +curl http://localhost:4040/debug/pprof/heap > heap.prof + +# Block profiling +./backend -blockprofile=10 +``` + +## Configuration + +### Structure (`config.toml`) + +```toml +[adj] +branch = "main" +test = false + +[vehicle] +boards = ["LCU", "HVSCU", "BMSL"] + +[tcp] +connection_timeout = 5000 +keep_alive = 1000 +backoff_multiplier = 1.5 + +[server.control-station] +addr = "0.0.0.0:8081" +static_path = "./control-station" +``` + +### Environment Overrides + +```bash +BACKEND_LOG_LEVEL=debug ./backend +BACKEND_ADJ_BRANCH=dev ./backend +``` + +## Error Handling + +### Error Types + +```go +// Structured errors with context +type ConnectionError struct { + Board string + Addr string + Err error + Timestamp time.Time +} + +func (e ConnectionError) Error() string { + return fmt.Sprintf("[%s] board %s at %s: %v", + e.Timestamp.Format(time.RFC3339), + e.Board, e.Addr, e.Err) +} +``` + +### Recovery Strategies + +1. **Connection Loss**: Exponential backoff reconnection +2. **Decode Errors**: Log and continue processing +3. **Panic Recovery**: Goroutine-level recovery +4. **Resource Exhaustion**: Circuit breakers + +## Critical Issues + +### 1. BLCU Hardcoding + +```go +// FIXME: Move to ADJ configuration +const ( + BlcuDownloadOrderId = 701 + BlcuUploadOrderId = 700 +) +``` + +**Impact**: Cannot adapt to different BLCU versions +**Solution**: Define BLCU packets in ADJ like other boards + +### 2. Large Monolithic Files + +- `main.go`: 800+ lines +- Complex initialization logic +- Hard to test + +**Solution**: Refactor into logical modules + +### 3. Test Coverage + +Current coverage: ~30% +**Critical gaps**: +- Connection failure scenarios +- Concurrent access patterns +- Edge cases in packet decoding + +## Development Guidelines + +### Adding New Board Types + +1. Define board in ADJ: +```json +{ + "board_id": 5, + "board_ip": "192.168.1.5", + "measurements": ["measurements.json"], + "packets": ["packets.json", "orders.json"] +} +``` + +2. Register in config.toml: +```toml +[vehicle] +boards = ["LCU", "HVSCU", "BMSL", "NEWBOARD"] +``` + +3. Implement board-specific logic if needed: +```go +type NewBoard struct { + api abstraction.BoardAPI +} + +func (b *NewBoard) Notify(notification abstraction.BoardNotification) { + // Handle board-specific notifications +} +``` + +### Testing + +```bash +# Run all tests +go test ./... + +# Run with coverage +go test -coverprofile=coverage.out ./... +go tool cover -html=coverage.out + +# Run specific package tests +go test -v ./pkg/transport/... + +# Benchmark tests +go test -bench=. ./pkg/transport/presentation/ +``` + +### Debugging + +```bash +# Enable trace logging +./backend -trace=trace + +# Enable pprof endpoints +./backend # pprof available at localhost:4040 + +# Use delve debugger +dlv debug ./cmd/main.go + +# Trace specific boards +tail -f trace.json | jq 'select(.board == "LCU")' +``` + +## Future Roadmap + +### Short Term (1-2 months) +- [ ] Move BLCU configuration to ADJ +- [ ] Increase test coverage to 50% +- [ ] Refactor main.go into modules +- [ ] Add connection pooling limits + +### Medium Term (3-6 months) +- [ ] Plugin system for board types +- [ ] Configuration hot reload +- [ ] Distributed deployment support +- [ ] Advanced message filtering + +### Long Term (6+ months) +- [ ] GraphQL API alongside WebSocket +- [ ] Time-series database integration +- [ ] Horizontal scaling support +- [ ] Full telemetry system + +--- + +*For complete system documentation including packet flow and troubleshooting, see the [Control Station Complete Architecture](../../CONTROL_STATION_COMPLETE_ARCHITECTURE.md).* \ No newline at end of file diff --git a/docs/architecture/binary-protocol.md b/docs/architecture/binary-protocol.md new file mode 100644 index 000000000..798a998d6 --- /dev/null +++ b/docs/architecture/binary-protocol.md @@ -0,0 +1,276 @@ +# Binary Protocol Specification + +This document defines the binary wire protocol used for communication between vehicle boards and the backend. + +## Protocol Overview + +All communication uses a simple, efficient binary protocol with fixed headers and variable-length payloads. + +### Endianness +**All multi-byte values use little-endian byte order.** + +### Packet Structure +``` +┌─────────────┬─────────────────────────┐ +│ Header (2B) │ Payload (variable) │ +├─────────────┼─────────────────────────┤ +│ Packet ID │ Data fields per ADJ │ +│ (uint16) │ specification │ +└─────────────┴─────────────────────────┘ +``` + +## Data Type Encoding + +### Numeric Types +| Type | Size | Range | Encoding | +|------|------|-------|----------| +| uint8 | 1 byte | 0 to 255 | Raw byte | +| uint16 | 2 bytes | 0 to 65,535 | Little-endian | +| uint32 | 4 bytes | 0 to 4,294,967,295 | Little-endian | +| uint64 | 8 bytes | 0 to 18,446,744,073,709,551,615 | Little-endian | +| int8 | 1 byte | -128 to 127 | Two's complement | +| int16 | 2 bytes | -32,768 to 32,767 | Two's complement, little-endian | +| int32 | 4 bytes | -2,147,483,648 to 2,147,483,647 | Two's complement, little-endian | +| int64 | 8 bytes | -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807 | Two's complement, little-endian | +| float32 | 4 bytes | IEEE 754 single precision | Little-endian | +| float64 | 8 bytes | IEEE 754 double precision | Little-endian | + +### Boolean Type +- Encoded as uint8: 0 = false, non-zero = true + +### Enum Type +- Encoded as uint8 representing the index in the enum definition + +## Packet Categories + +### 1. Data Packets (Board → Backend) +Used for sensor measurements and telemetry. + +#### Example: Temperature Sensor +``` +ADJ Definition: +{ + "id": 100, + "variables": ["sensor_id", "temperature", "timestamp"] +} + +Measurements: +- sensor_id: uint8 +- temperature: float32 (°C) +- timestamp: uint32 (milliseconds) + +Binary encoding: +64 00 // Packet ID: 100 +01 // sensor_id: 1 +CD CC 8C 41 // temperature: 17.6°C (float32) +10 27 00 00 // timestamp: 10000ms +``` + +### 2. Protection Packets (Board → Backend) +Safety-critical notifications with severity levels. + +#### Packet ID Ranges +- 1000-1999: Fault (critical failures) +- 2000-2999: Warning (non-critical issues) +- 3000-3999: OK (cleared conditions) + +#### Structure +``` +┌──────────────┬──────────────┬──────────────┐ +│ Packet ID │ Board ID │ Error Code │ +│ (uint16) │ (uint16) │ (uint8) │ +└──────────────┴──────────────┴──────────────┘ +``` + +#### Example: Overheat Fault +``` +E8 03 // ID: 1000 (fault) +04 00 // Board ID: 4 (LCU) +15 // Error code: 21 (overheat) +``` + +### 3. Order Packets (Backend → Board) +Commands sent from backend to boards. + +#### Example: Set Current +``` +ADJ Definition: +{ + "id": 9995, + "variables": ["ldu_id", "lcu_desired_current"] +} + +Binary encoding: +0B 27 // ID: 9995 +02 // ldu_id: 2 +00 00 20 40 // desired_current: 2.5A (float32) +``` + +### 4. State Order Packets +Special packets for managing periodic order execution. + +#### Add State Order (Enable periodic execution) +``` +Structure: +┌──────────────┬──────────────┬──────────────┐ +│ Packet ID │ Board Name │ Order IDs │ +│ (uint16) │ Length + Str │ Count + List │ +└──────────────┴──────────────┴──────────────┘ + +Example: +05 00 // ID: 5 (add_state_order) +03 // String length: 3 +4C 43 55 // "LCU" +02 00 // Order count: 2 +0B 27 // Order ID: 9995 +0C 27 // Order ID: 9996 +``` + +#### Remove State Order (Disable periodic execution) +Same structure as Add State Order but with ID 6. + +### 5. BLCU Packets +Special packets for bootloader operations. + +#### Download Order (ID: 50) +``` +32 00 // ID: 50 +[filename] // Null-terminated string +[file_size] // uint32 +[checksum] // uint32 +``` + +#### Upload Order (ID: 51) +``` +33 00 // ID: 51 +[filename] // Null-terminated string +``` + +## Special Packet IDs + +### ID 0: Fault Propagation +When any board sends a packet with ID 0, the backend immediately replicates it to all connected boards. This enables system-wide emergency stop. + +``` +00 00 // ID: 0 (emergency stop) +[payload] // Board-specific fault data +``` + +## Unit Conversions + +The protocol supports automatic unit conversions between pod units and display units. + +### Conversion Operations +- `*X`: Multiply by X +- `/X`: Divide by X +- `+X`: Add X +- `-X`: Subtract X + +### Example: Temperature Conversion +``` +Pod units: Celsius +Display units: Kelvin +Conversion: "+273.15" + +Wire value: 25.0°C (float32: 0x41C80000) +Display value: 298.15K +``` + +## Transport Protocols + +### TCP (Reliable Commands) +- Used for: Orders, critical data, connection management +- Port: 50500 (board → backend), 50401 (backend → board) +- Features: Guaranteed delivery, order preservation + +### UDP (High-Speed Data) +- Used for: Sensor data, non-critical telemetry +- Port: 50400 +- Features: Low latency, no retransmission + +## Error Handling + +### Malformed Packets +- Invalid packet ID: Log and drop +- Incorrect length: Close connection +- Decode failure: Send protection fault + +### Connection Errors +- TCP disconnect: Automatic reconnection +- UDP packet loss: Update statistics +- Timeout: Configurable retry logic + +## Performance Considerations + +### Packet Size Limits +- TCP: No inherent limit (fragmented as needed) +- UDP: 1500 bytes (Ethernet MTU) +- Recommended: Keep packets under 1000 bytes + +### Throughput +- Design target: 1000+ packets/second per board +- Actual limit: Network and processing dependent + +### Latency +- TCP: ~1-5ms typical +- UDP: <1ms typical +- Critical path (protection): <5ms requirement + +## Security Notes + +### Current Implementation +- No encryption (trusted network assumed) +- No authentication +- Basic validation only + +### Recommendations +- Add packet signing for critical commands +- Implement sequence numbers +- Consider TLS for external connections + +## Debugging + +### Packet Capture +Use Wireshark with these filters: +``` +# All pod traffic +ip.addr == 192.168.1.0/24 + +# Specific board +ip.addr == 192.168.1.4 + +# UDP sensor data only +udp.port == 50400 +``` + +### Common Issues +1. **Byte order mismatch**: Check endianness +2. **Packet truncation**: Verify buffer sizes +3. **ID not recognized**: Check ADJ configuration +4. **Float precision**: Use float32 unless needed + +## Implementation Notes + +### C/C++ (Firmware) +```c +// Pack structure (GCC) +struct __attribute__((packed)) DataPacket { + uint16_t id; + uint8_t sensor_id; + float temperature; + uint32_t timestamp; +}; +``` + +### Go (Backend) +```go +// Use encoding/binary +binary.Write(buffer, binary.LittleEndian, packet) +``` + +### Python (Testing) +```python +# Use struct module +import struct +data = struct.pack('; +} + +interface OrderField { + value: string | number | boolean; + isEnabled: boolean; + type: string; // Data type for encoding +} +``` + +#### Data Flow +1. **Subscriptions**: Components subscribe to WebSocket topics +2. **Updates**: Real-time data pushed from backend +3. **State Management**: Updates stored in Redux/Zustand +4. **Rendering**: React components re-render on state changes + +## State Management + +### Redux Store (Legacy) +Used for complex application state: +- Board configurations +- Historical data +- User preferences + +### Zustand Stores +Modern stores for specific features: +- **Orders Store**: Order definitions and state +- **Connection Store**: Board connection status +- **Logger Store**: Logging configuration + +### Local Component State +Used for: +- UI interactions (modals, dropdowns) +- Form inputs +- Temporary values + +## WebSocket Communication + +### Topic Subscriptions +```typescript +// Subscribe to data updates +handler.subscribe("podData/update", { + id: uniqueId, + cb: (data) => updateTelemetry(data) +}); + +// Send order +handler.post("order/send", orderPayload); + +// Exchange pattern for BLCU +handler.exchange("blcu/upload", request, id, (response) => { + // Handle progressive updates +}); +``` + +### Message Flow +1. **Connection**: WebSocket connects to backend +2. **Handshake**: Re-establish subscriptions on reconnect +3. **Message Reception**: JSON messages parsed and routed +4. **State Update**: Stores updated with new data +5. **UI Update**: Components re-render + +## Performance Optimizations + +### Rendering Optimizations +- **React.memo**: Prevent unnecessary re-renders +- **useMemo/useCallback**: Optimize expensive computations +- **Virtualization**: For large data lists +- **Throttling**: Limit update frequency for high-rate data + +### Data Management +- **Selective Subscriptions**: Only subscribe to needed data +- **Data Aggregation**: Backend aggregates before sending +- **Caching**: Cache static data locally +- **Cleanup**: Unsubscribe when components unmount + +### Bundle Optimization +- **Code Splitting**: Lazy load heavy components +- **Tree Shaking**: Remove unused code +- **Compression**: Gzip/Brotli for production +- **CDN**: Serve static assets from CDN + +## Development Workflow + +### Project Structure +``` +control-station/ +├── src/ +│ ├── components/ # UI components +│ ├── pages/ # Page-level components +│ ├── services/ # API and WebSocket +│ ├── hooks/ # Custom hooks +│ ├── styles/ # Global styles +│ └── types/ # TypeScript definitions +├── public/ # Static assets +└── vite.config.ts # Build configuration +``` + +### Development Tools +- **Vite Dev Server**: Hot module replacement +- **TypeScript**: Type checking and IntelliSense +- **ESLint**: Code quality enforcement +- **Prettier**: Code formatting +- **React DevTools**: Component debugging + +### Building and Deployment +```bash +# Development +npm run dev # Start dev server + +# Production +npm run build # Create production build +npm run preview # Preview production build +``` + +## Common Patterns + +### Custom Hooks +```typescript +// WebSocket subscription hook +function useSubscription(topic: string, callback: (data: T) => void) { + const handler = useWsHandler(); + + useEffect(() => { + const id = nanoid(); + handler.subscribe(topic, { id, cb: callback }); + return () => handler.unsubscribe(topic, id); + }, [topic]); +} +``` + +### Error Boundaries +```typescript +class ErrorBoundary extends Component { + // Catch and display errors gracefully + // Log errors to monitoring service + // Provide fallback UI +} +``` + +### Loading States +```typescript +function DataPanel() { + const { data, loading, error } = useData(); + + if (loading) return ; + if (error) return ; + return ; +} +``` + +## Testing Strategy + +### Unit Tests +- Component logic testing +- Hook behavior verification +- Utility function testing + +### Integration Tests +- WebSocket communication +- State management flows +- User interaction scenarios + +### E2E Tests +- Critical user journeys +- Cross-browser compatibility +- Performance benchmarks + +## Security Considerations + +### Current Implementation +- No authentication (trusted network) +- Input validation on forms +- XSS protection via React +- Content Security Policy headers + +### Recommended Improvements +- Token-based authentication +- Request signing +- Rate limiting +- Audit logging + +## Future Enhancements + +### Planned Features +1. **Real-time Collaboration**: Multi-user support +2. **Advanced Visualizations**: 3D pod representation +3. **Machine Learning**: Anomaly detection +4. **Mobile Support**: Responsive design +5. **Offline Mode**: Local data caching + +### Technical Improvements +1. **Type Generation**: Auto-generate from ADJ +2. **Component Library**: Storybook documentation +3. **Performance Monitoring**: Real user metrics +4. **Accessibility**: WCAG compliance +5. **Internationalization**: Multi-language support \ No newline at end of file diff --git a/docs/architecture/issues-and-improvements.md b/docs/architecture/issues-and-improvements.md new file mode 100644 index 000000000..b9e5e5568 --- /dev/null +++ b/docs/architecture/issues-and-improvements.md @@ -0,0 +1,290 @@ +# Control Station - Issues and Improvement Recommendations + +## Critical Issues + +### 1. BLCU Hardcoded Configuration +**Issue**: BLCU packet IDs and configuration are hardcoded in the backend +```go +const ( + BlcuDownloadOrderId = 50 + BlcuUploadOrderId = 51 +) +``` +**Impact**: Cannot adapt to different BLCU versions or configurations +**Solution**: Move BLCU configuration to ADJ specification like other boards + +### 2. Missing Error Recovery Documentation +**Issue**: No clear documentation on error recovery procedures +**Impact**: Operators don't know how to recover from common failures +**Solution**: Create operational runbooks for common scenarios + +### 3. Inconsistent Error Handling +**Issue**: Error handling varies across packages +- Some use wrapped errors +- Some panic +- Some silently log +**Impact**: Difficult to debug issues in production +**Solution**: Implement consistent error handling strategy with proper error types + +## High Priority Improvements + +### 1. WebSocket Message Type Safety +**Current State**: Frontend uses `any` types for WebSocket payloads +```typescript +payload: any; // No type safety +``` +**Improvement**: Generate TypeScript types from ADJ specifications +**Benefits**: +- Compile-time error detection +- Better IDE support +- Reduced runtime errors + +### 2. Connection Pool Management +**Current State**: Simple map with mutex protection +```go +connections map[abstraction.TransportTarget]net.Conn +``` +**Improvement**: Implement proper connection pool with: +- Health checks +- Connection limits +- Metrics tracking +- Load balancing (for multiple boards of same type) + +### 3. Packet Validation +**Current State**: Limited validation after decoding +**Improvement**: Add validation layer: +- Range checking based on ADJ limits +- Rate limiting per packet type +- Anomaly detection +- Data integrity checks + +### 4. Configuration Hot Reload +**Current State**: Requires restart for configuration changes +**Improvement**: Implement configuration watcher: +- Hot reload for non-critical settings +- Graceful handling of ADJ updates +- Configuration validation before apply + +## Medium Priority Improvements + +### 1. Testing Infrastructure +**Current Coverage**: ~30% (estimated) +**Target**: 80%+ coverage +**Areas Needing Tests**: +- Packet encoding/decoding edge cases +- Connection failure scenarios +- WebSocket message handling +- Concurrent access patterns + +### 2. Performance Monitoring +**Current State**: Basic logging only +**Improvement**: Add metrics collection: +- Prometheus metrics endpoint +- Packet processing latency +- Queue depths +- Memory usage patterns +- Connection statistics + +### 3. Documentation Gaps +**Missing Documentation**: +- ADJ specification format details +- Board development guide +- Performance tuning guide +- Security hardening guide +- Deployment best practices + +### 4. Frontend State Management +**Current State**: Mixed patterns (Redux, Zustand, local state) +**Improvement**: Consolidate to single state management solution +**Benefits**: +- Consistent patterns +- Better debugging +- Time-travel debugging +- State persistence + +## Code Quality Issues + +### 1. Circular Dependencies +**Found In**: Internal packages have some circular references +**Solution**: Refactor to clear dependency hierarchy + +### 2. Large Files +**Examples**: +- `main.go`: 800+ lines +- Some frontend components: 500+ lines +**Solution**: Split into logical modules + +### 3. Magic Numbers +**Examples**: +```go +time.Second / 10 // What is this interval for? +64KB // Buffer size - why this value? +``` +**Solution**: Named constants with documentation + +### 4. Inconsistent Naming +**Examples**: +- `podData` vs `pod_data` +- `WsHandler` vs `WebSocketHandler` +**Solution**: Adopt consistent naming conventions + +## Architecture Improvements + +### 1. Plugin System +**Current**: All packet types compiled in +**Proposed**: Dynamic loading of packet handlers +**Benefits**: +- Easier board additions +- Reduced binary size +- Hot-swappable handlers + +### 2. Message Queue Integration +**Current**: In-memory broker only +**Proposed**: Optional persistent message queue (Redis/RabbitMQ) +**Benefits**: +- Message persistence +- Horizontal scaling +- Better fault tolerance + +### 3. Microservice Option +**Current**: Monolithic backend +**Proposed**: Optional microservice deployment +**Benefits**: +- Independent scaling +- Technology diversity +- Fault isolation + +### 4. Advanced Routing +**Current**: Simple topic-based routing +**Proposed**: Content-based routing with filters +**Benefits**: +- Reduced network traffic +- Client-specific data streams +- Better performance + +## Security Enhancements + +### 1. Authentication +**Current**: None (trusted network assumed) +**Needed**: +- Token-based auth for WebSocket +- Certificate-based auth for boards +- API key management + +### 2. Encryption +**Current**: Plaintext communication +**Needed**: +- TLS for external connections +- Optional encryption for board communication +- Secure key exchange + +### 3. Audit Logging +**Current**: Basic operational logs +**Needed**: +- Who did what when +- Configuration changes +- Critical commands +- Access patterns + +### 4. Rate Limiting +**Current**: None +**Needed**: +- Per-client limits +- Per-packet-type limits +- Burst handling +- DDoS protection + +## Operational Improvements + +### 1. Health Checks +**Current**: Basic ping/pong +**Needed**: +- Comprehensive health endpoint +- Dependency checks +- Performance metrics +- Ready/live probes + +### 2. Graceful Degradation +**Current**: All-or-nothing operation +**Needed**: +- Operate with partial boards +- Fallback modes +- Circuit breakers +- Bulkheading + +### 3. Deployment Automation +**Current**: Manual deployment +**Needed**: +- CI/CD pipeline +- Automated testing +- Blue-green deployment +- Rollback procedures + +### 4. Monitoring Integration +**Current**: Logs only +**Needed**: +- Grafana dashboards +- Alert rules +- SLO tracking +- Incident response + +## Developer Experience + +### 1. Development Environment +**Issues**: +- Complex setup process +- Platform-specific scripts +- Dependency management +**Solutions**: +- Docker-based development +- Unified script interface +- Better documentation + +### 2. Debugging Tools +**Current**: Basic logging +**Needed**: +- Packet inspector UI +- Message flow visualizer +- Performance profiler +- Debug mode with verbose output + +### 3. Code Generation +**Current**: Manual synchronization with ADJ +**Needed**: +- Auto-generate types from ADJ +- Validation code generation +- Documentation generation +- Test case generation + +## Priority Matrix + +### Immediate (This Week) +1. Document critical operational procedures +2. Fix BLCU hardcoding +3. Improve error messages + +### Short Term (This Month) +1. Increase test coverage to 50% +2. Implement basic metrics +3. Consolidate error handling + +### Medium Term (This Quarter) +1. Type safety improvements +2. Performance monitoring +3. Security audit + +### Long Term (This Year) +1. Plugin architecture +2. Microservice option +3. Advanced routing features + +## Migration Path + +For each improvement: +1. Design detailed specification +2. Implement behind feature flag +3. Test in parallel with existing code +4. Gradual rollout +5. Remove old code after validation + +This ensures system stability while improving the codebase incrementally. \ No newline at end of file diff --git a/docs/architecture/message-structures.md b/docs/architecture/message-structures.md new file mode 100644 index 000000000..fa50b4d40 --- /dev/null +++ b/docs/architecture/message-structures.md @@ -0,0 +1,525 @@ +# Message Structures and Communication Protocols + +This document provides a comprehensive specification of all message structures and communication protocols used between the vehicle, backend, and frontend components of the Hyperloop Control Station. + +## Overview + +The communication system uses a three-layer architecture: + +1. **Vehicle ↔ Backend**: Binary packet protocols over TCP/UDP +2. **Backend ↔ Frontend**: JSON messages over WebSocket +3. **Internal Backend**: Go struct-based message routing + +## Table of Contents + +- [1. Vehicle to Backend Communication](#1-vehicle-to-backend-communication) +- [2. Backend to Frontend Communication](#2-backend-to-frontend-communication) +- [3. Backend Internal Message Flow](#3-backend-internal-message-flow) +- [4. Message Timing and Ordering](#4-message-timing-and-ordering) +- [5. Error Handling and Fault Tolerance](#5-error-handling-and-fault-tolerance) + +--- + +## 1. Vehicle to Backend Communication + +### 1.1 Transport Layer + +**Protocol**: TCP (primary) and UDP (secondary) +**Ports**: Defined in `adj/general_info.json` +- TCP_CLIENT: 50401 (backend connects to vehicle) +- TCP_SERVER: 50500 (vehicle connects to backend) +- UDP: 8000 (bidirectional communication) +- TFTP: 69 (file transfers) + +**Endianness**: Little-endian for all multi-byte fields + +### 1.2 Binary Packet Structure + +All packets follow this general structure: + +``` +Byte Offset: 0 2 4 6 8+ + |========|========|========|========|========| + | Packet ID | Payload Data... | + | (uint16) | (variable length) | + |========|========|========|========|========| +``` + +**Packet ID**: 16-bit unsigned integer identifying the packet type (defined in ADJ specifications) + +### 1.3 Incoming Message Types + +#### 1.3.1 Data Packets + +**Purpose**: Continuous telemetry data from vehicle sensors +**Go Type**: `pkg/transport/packet/data.Packet` + +**Binary Structure**: +``` +Byte Offset: 0 2 4 6 8+ + |========|========|========|========|========| + | Packet ID | Data Values... | + | (uint16) | (per ADJ spec) | + |========|========|========|========|========| +``` + +**Go Structure**: +```go +type Packet struct { + id abstraction.PacketId // uint16 packet identifier + values map[ValueName]Value // Measurement name -> value + enabled map[ValueName]bool // Measurement name -> enabled state + timestamp time.Time // Packet reception time +} +``` + +**Value Types**: +- `NumericValue[T]`: For numeric data (uint8-uint64, int8-int64, float32, float64) +- `BooleanValue`: For boolean measurements +- `EnumValue`: For discrete state values + +**Example ADJ Data Packet Definition**: +```json +{ + "id": 211, + "name": "vcu_regulator_packet", + "type": "data", + "variables": ["valve_state", "reference_pressure", "actual_pressure"] +} +``` + +#### 1.3.2 Protection Packets + +**Purpose**: Safety alerts and fault notifications +**Go Type**: `pkg/transport/packet/protection.Packet` + +**Binary Structure** (per package documentation): +``` +Byte Offset: 0 8 16 24 32+ + |========|========|========|========|========| + | Packet ID | type | kind | ... | + | (uint16) | (uint8)|(uint8) | | + |========|========|========|========|========| + | Name (null-terminated string) | + |========|========|========|========|========| + | Protection Data (variable length) | + |========|========|========|========|========| + |counter |counter | second | minute | hour | + |(uint16)|(uint16)|(uint8) |(uint8) |(uint8) | + |========|========|========|========|========| + | day | month | year | + |(uint8) |(uint8) | (uint16) | + |========|========|========|========| +``` + +**Go Structure**: +```go +type Packet struct { + id abstraction.PacketId // Packet identifier + Type Type // Data type being protected + Kind Kind // Protection condition type + Name string // Human-readable protection name + Data Data // Protection-specific data + Timestamp *Timestamp // RTC timestamp from vehicle + severity Severity // Protection severity level +} +``` + +**Protection Types**: +- `FaultSeverity`: Critical faults requiring immediate system shutdown +- `WarningSeverity`: Warnings requiring operator attention +- `OkSeverity`: Recovery from previous fault/warning state + +**Protection Kinds**: +- `BelowKind`: Value below threshold +- `AboveKind`: Value above threshold +- `OutOfBoundsKind`: Value outside acceptable range +- `EqualsKind`: Value equals specific condition +- `NotEqualsKind`: Value doesn't equal expected condition +- `ErrorHandlerKind`: System error condition +- `TimeAccumulationKind`: Time-based accumulation fault +- `WarningKind`: General warning condition + +#### 1.3.3 State Space Packets + +**Purpose**: Control system state information +**Go Type**: `pkg/transport/packet/state.Space` + +**Go Structure**: +```go +type Space struct { + id abstraction.PacketId // Packet identifier + state [8][15]float32 // 8x15 state matrix +} +``` + +**Usage**: Contains control parameters and state variables used by the vehicle's control algorithms. + +#### 1.3.4 BLCU Packets + +**Purpose**: Bootloader Communication Unit responses +**Go Type**: `pkg/transport/packet/blcu.Packet` + +**Usage**: Handles firmware update acknowledgments and bootloader communication during vehicle programming operations. + +### 1.4 Outgoing Message Types + +#### 1.4.1 Order Packets + +**Purpose**: Commands sent from backend to vehicle +**Go Types**: `pkg/transport/packet/order.Add`, `pkg/transport/packet/order.Remove` + +**Add Order Structure**: +```go +type Add struct { + id abstraction.PacketId // Packet identifier + orders []abstraction.PacketId // List of order IDs to enable +} +``` + +**Remove Order Structure**: +```go +type Remove struct { + id abstraction.PacketId // Packet identifier + orders []abstraction.PacketId // List of order IDs to disable +} +``` + +**Example ADJ Order Definition**: +```json +{ + "type": "order", + "name": "Brake Command", + "variables": ["brake_command", "target_pressure"] +} +``` + +### 1.5 File Transfer Protocol + +**Protocol**: TFTP (Trivial File Transfer Protocol) +**Port**: 69 +**Go Types**: `FileWriteMessage`, `FileReadMessage` + +**File Write (Backend → Vehicle)**: +```go +type FileWriteMessage struct { + filename string // Target filename on vehicle + io.Reader // File content source +} +``` + +**File Read (Vehicle → Backend)**: +```go +type FileReadMessage struct { + filename string // Source filename on vehicle + io.Writer // File content destination +} +``` + +**Usage**: Primarily used for firmware updates and configuration file transfers. + +--- + +## 2. Backend to Frontend Communication + +### 2.1 Transport Layer + +**Protocol**: WebSocket over HTTP +**Content Type**: JSON +**Go Type**: `pkg/websocket.Message` + +### 2.2 WebSocket Message Structure + +**Base Message Format**: +```go +type Message struct { + Topic abstraction.BrokerTopic `json:"topic"` // Message routing topic + Payload json.RawMessage `json:"payload"` // Topic-specific data +} +``` + +**JSON Wire Format**: +```json +{ + "topic": "data/update", + "payload": { /* topic-specific payload */ } +} +``` + +### 2.3 Message Topics + +#### 2.3.1 Data Update Messages + +**Topic**: `"data/update"` +**Direction**: Backend → Frontend +**Purpose**: Real-time sensor data updates + +**Payload Structure**: +```json +{ + "board_id": 0, + "packet_id": 211, + "packet_name": "vcu_regulator_packet", + "timestamp": "2024-01-15T10:30:45.123Z", + "measurements": { + "valve_state": { + "value": "open", + "type": "enum", + "enabled": true + }, + "reference_pressure": { + "value": 8.5, + "type": "float32", + "enabled": true, + "units": "bar" + }, + "actual_pressure": { + "value": 8.3, + "type": "float32", + "enabled": true, + "units": "bar" + } + } +} +``` + +#### 2.3.2 Connection Status Messages + +**Topic**: `"connection/update"` +**Direction**: Backend → Frontend +**Purpose**: Vehicle connection status updates + +**Payload Structure**: +```json +{ + "board_id": 0, + "board_name": "VCU", + "ip_address": "127.0.0.6", + "connected": true, + "last_seen": "2024-01-15T10:30:45.123Z", + "packets_received": 1250, + "connection_quality": "excellent" +} +``` + +#### 2.3.3 Protection Messages + +**Topic**: `"protection/alert"` +**Direction**: Backend → Frontend +**Purpose**: Safety alerts and fault notifications + +**Payload Structure**: +```json +{ + "board_id": 0, + "packet_id": 2, + "severity": "fault", + "protection_type": "above", + "measurement_name": "brake_pressure", + "current_value": 105.0, + "threshold_value": 100.0, + "message": "Brake pressure above safe limit", + "timestamp": "2024-01-15T10:30:45.123Z", + "acknowledged": false +} +``` + +#### 2.3.4 Order Status Messages + +**Topic**: `"order/state"` +**Direction**: Backend → Frontend +**Purpose**: Command execution status updates + +**Payload Structure**: +```json +{ + "board_id": 0, + "order_name": "brake_engage", + "status": "executed", + "timestamp": "2024-01-15T10:30:45.123Z", + "parameters": { + "target_pressure": 85.0 + } +} +``` + +#### 2.3.5 Logger Messages + +**Topic**: `"logger/enable"`, `"logger/disable"` +**Direction**: Frontend → Backend +**Purpose**: Control data logging + +**Enable Payload**: +```json +{ + "measurements": ["brake_pressure", "valve_state"], + "log_rate": 100, + "format": "csv" +} +``` + +**Disable Payload**: +```json +{ + "stop_logging": true +} +``` + +#### 2.3.6 BLCU Messages + +**Topic**: `"blcu/upload"`, `"blcu/download"` +**Direction**: Bidirectional +**Purpose**: Bootloader operations + +**Upload Request Payload**: +```json +{ + "board_id": 0, + "filename": "firmware_v2.1.bin", + "file_size": 524288, + "checksum": "a1b2c3d4" +} +``` + +**Upload Status Payload**: +```json +{ + "board_id": 0, + "operation": "upload", + "status": "in_progress", + "progress": 0.65, + "bytes_transferred": 340000, + "estimated_time_remaining": 45 +} +``` + +--- + +## 3. Backend Internal Message Flow + +### 3.1 Transport Layer Messages + +**Go Type**: `transport.PacketMessage`, `transport.FileWriteMessage`, `transport.FileReadMessage` + +**Internal Message Events**: +- `PacketEvent`: Packet send/receive operations +- `ErrorEvent`: Transport layer errors +- `FileWriteEvent`: TFTP write requests +- `FileReadEvent`: TFTP read requests + +### 3.2 Broker System + +**Architecture**: Topic-based message routing +**Go Type**: `pkg/broker.Broker` + +**Message Flow**: +1. Transport layer receives packet from vehicle +2. Packet decoded via presentation layer (`pkg/transport/presentation.Decoder`) +3. Packet routed to appropriate board handler via broker +4. Board handler processes packet and generates WebSocket messages +5. WebSocket messages broadcast to connected frontend clients + +### 3.3 Vehicle Management + +**Go Type**: `pkg/vehicle.Vehicle` + +**Responsibilities**: +- Board lifecycle management +- Order execution coordination +- State space management +- Protection system coordination + +--- + +## 4. Message Timing and Ordering + +### 4.1 Data Packet Timing + +**Frequency**: Variable per packet type, typically 10-100 Hz +**Buffering**: Session-level buffering for network packet reassembly +**Ordering**: Packets processed in receive order, timestamped on arrival + +### 4.2 Protection Message Priority + +**Priority Order**: +1. Fault messages (immediate processing) +2. Warning messages (high priority queue) +3. OK messages (normal priority queue) + +### 4.3 Order Execution Timing + +**Request Flow**: +1. Frontend sends order via WebSocket +2. Backend validates order parameters +3. Backend sends binary order packet to vehicle +4. Vehicle acknowledgment expected within 1 second +5. Status update sent to frontend + +--- + +## 5. Error Handling and Fault Tolerance + +### 5.1 Network Error Handling + +**TCP Connection Failures**: +- Automatic reconnection with exponential backoff +- Connection status broadcast to frontend +- Packet queuing during disconnection + +**UDP Packet Loss**: +- No retransmission (fire-and-forget) +- Packet sequence tracking for loss detection +- Statistics reporting to frontend + +### 5.2 Protocol Error Handling + +**Invalid Packet Format**: +- Packet discarded with error logging +- Decoder error recovery to next packet boundary +- Error statistics tracked per board + +**Unknown Packet IDs**: +- Warning logged with packet ID +- Packet contents logged for debugging +- Connection maintained + +### 5.3 ADJ Configuration Errors + +**Missing Measurements**: +- Default value substitution where possible +- Warning alerts to operators +- Graceful degradation of functionality + +**Invalid Board Configuration**: +- Board disabled with error notification +- System continues with remaining boards +- Configuration reload capability + +--- + +## Implementation Notes + +### Network Layer + +The network implementation (`pkg/transport/network/`) provides: +- **TCP Client/Server**: Reliable packet delivery +- **UDP**: Fast, unreliable communication for high-frequency data +- **TFTP**: File transfer capabilities +- **Packet Sniffer**: Network traffic monitoring and debugging + +### Presentation Layer + +The presentation layer (`pkg/transport/presentation/`) handles: +- **Packet ID routing**: Maps packet IDs to appropriate decoders +- **Binary decoding**: Converts binary data to Go structs +- **Encoder support**: Converts Go structs to binary for transmission + +### Data Types + +All data types support: +- **Type safety**: Strong typing prevents runtime errors +- **JSON serialization**: Automatic conversion for WebSocket transmission +- **Value validation**: Range checking and enum validation +- **Unit conversion**: Automatic conversion between pod and display units + +This documentation should be kept synchronized with ADJ specification changes and backend implementation updates. \ No newline at end of file diff --git a/docs/architecture/packet-flow-reference.md b/docs/architecture/packet-flow-reference.md new file mode 100644 index 000000000..8bee23e10 --- /dev/null +++ b/docs/architecture/packet-flow-reference.md @@ -0,0 +1,183 @@ +# Packet Flow Quick Reference + +## Board → Backend → Frontend (Data Flow) + +### 1. Binary Packet Structure +``` +[Packet ID: 2 bytes (uint16, little-endian)] [Data: variable length based on ADJ] +``` + +### 2. Network Reception +- **TCP**: Reliable delivery for critical data +- **UDP**: High-frequency sensor data (no retransmission) +- Board IP must match ADJ configuration + +### 3. Decoding Process +```go +// Simplified flow +id := readUint16(connection) // Read 2-byte packet ID +decoder := getDecoderForId(id) // Lookup from ADJ +packet := decoder.Decode(connection) // Parse remaining bytes +applyUnitConversions(packet) // Convert units if needed +``` + +### 4. Message Routing +``` +Packet Type → Broker Topic → WebSocket Message +Data → data/update → {"topic": "data/update", "payload": {...}} +Protection → protection/* → {"topic": "protection/fault", "payload": {...}} +``` + +### 5. Frontend Reception +```typescript +handler.subscribe("data/update", { + id: "unique-id", + cb: (data) => updateUI(data) +}); +``` + +## Frontend → Backend → Board (Order Flow) + +### 1. Order Creation +```typescript +const order: Order = { + id: 9995, // From ADJ orders.json + fields: { + "ldu_id": { value: 1, isEnabled: true, type: "uint8" }, + "lcu_desired_current": { value: 2.5, isEnabled: true, type: "float32" } + } +}; +``` + +### 2. WebSocket Transmission +```typescript +handler.post("order/send", order); +// Sends: {"topic": "order/send", "payload": order} +``` + +### 3. Backend Processing +1. Receive JSON order from WebSocket +2. Validate order ID exists in ADJ +3. Create packet structure from order fields +4. Encode to binary format (little-endian) +5. Lookup target board from packet ID + +### 4. Binary Encoding +``` +Order ID: 9995 (0x270B) +Fields: ldu_id=1, lcu_desired_current=2.5 + +Binary output: +[0B 27] // ID: 9995 in little-endian +[01] // ldu_id: 1 (uint8) +[00 00 20 40] // lcu_desired_current: 2.5 (float32, little-endian) +``` + +### 5. Transmission to Board +- TCP connection to board IP (from ADJ) +- Synchronous write with error handling +- Connection status tracked + +## Special Cases + +### Fault Propagation (ID: 0) +``` +When packet ID = 0: +1. Received from any board +2. Replicated to ALL connected boards +3. Triggers emergency stop procedures +``` + +### BLCU File Transfer +``` +1. Frontend: handler.exchange("blcu/upload", {filename, data}) +2. Backend: TFTP PUT to BLCU IP +3. Progress updates via WebSocket +4. Completion notification +``` + +### State Orders +``` +Special orders that modify board state: +- add_state_order: Enable periodic execution +- remove_state_order: Disable periodic execution +- Managed by backend state machine +``` + +## Common Packet Types + +### Data Packet Example (LCU Coil Current) +``` +ADJ Definition (packets.json): +{ + "id": 320, + "type": "data", + "name": "lcu_coil_current", + "variables": ["general_state", "vertical_state", ...] +} + +Binary Format: +[40 01] // ID: 320 +[01] // general_state (uint8) +[02] // vertical_state (uint8) +[00 00 80 3F] // coil_current_1 (float32: 1.0) +... +``` + +### Protection Packet Example +``` +ID Range: 1000-3999 (based on severity) +- 1xxx: Fault (critical) +- 2xxx: Warning +- 3xxx: OK (cleared) + +Binary Format: +[E8 03] // ID: 1000 (fault) +[05 00] // Board ID: 5 +[01] // Error code +``` + +## Network Configuration + +### Default Ports +- TCP Client: 50401 (Backend → Board) +- TCP Server: 50500 (Board → Backend) +- UDP: 50400 (Bidirectional) +- TFTP: 69 (BLCU only) +- WebSocket: 8080 (Frontend → Backend) + +### IP Scheme +- Backend: 192.168.0.9 +- Boards: 192.168.1.x (defined in ADJ) +- BLCU: 192.168.1.254 (typical) + +## Debugging Tips + +### 1. Check Packet Flow +```bash +# Monitor WebSocket messages in browser +# DevTools → Network → WS → Messages + +# Backend logs +tail -f trace.json | jq 'select(.msg | contains("packet"))' +``` + +### 2. Verify ADJ Configuration +```bash +# Check board definition +cat adj/boards/LCU/LCU.json + +# Verify packet exists +jq '.[] | select(.id == 320)' adj/boards/LCU/packets.json +``` + +### 3. Connection Status +- Frontend: Check connection indicator +- Backend logs: "new connection" / "close" +- Network: `netstat -an | grep 504` + +### 4. Common Issues +- **No data**: Check board in config.toml vehicle.boards +- **Order fails**: Verify order ID in ADJ +- **Decode error**: Packet structure mismatch +- **Connection drops**: Network/firewall issues \ No newline at end of file diff --git a/docs/architecture/protocols.md b/docs/architecture/protocols.md new file mode 100644 index 000000000..24342d8ca --- /dev/null +++ b/docs/architecture/protocols.md @@ -0,0 +1,243 @@ +# Communication Protocols Overview + +This document provides a high-level overview of all communication protocols used in the Hyperloop Control Station system. + +## Architecture Overview + +The system uses a three-layer communication architecture: + +``` +┌─────────────┐ Binary Protocol ┌─────────────┐ WebSocket API ┌─────────────┐ +│ Vehicle │ ◄─────────────────────► │ Backend │ ◄─────────────────► │ Frontend │ +│ Systems │ TCP/UDP + TFTP │ Server │ JSON/WS │ Applications │ +└─────────────┘ └─────────────┘ └─────────────┘ +``` + +## Layer 1: Vehicle ↔ Backend Communication + +### Transport Protocols +- **TCP**: Reliable command/control messages +- **UDP**: High-frequency sensor data +- **TFTP**: File transfers (firmware, configs) + +### Message Types +1. **Data Packets**: Sensor measurements and telemetry +2. **Protection Packets**: Safety alerts and fault notifications +3. **Order Packets**: Commands from backend to vehicle +4. **State Space Packets**: Control system matrices +5. **BLCU Packets**: Bootloader communication + +### Key Characteristics +- **Binary Encoding**: Efficient, compact representation +- **Little-Endian**: Consistent byte ordering +- **Real-Time**: Sub-10ms latency for critical messages +- **ADJ-Defined**: Message structure defined by JSON specifications + +**Detailed Specification**: [Binary Protocol](./binary-protocol.md) + +## Layer 2: Backend ↔ Frontend Communication + +### Transport Protocol +- **WebSocket**: Full-duplex communication over HTTP +- **JSON Encoding**: Human-readable, self-describing messages + +### Topic-Based Routing +Messages are routed by topic strings: +- `data/*`: Measurement data and updates +- `connection/*`: Network status and board connectivity +- `order/*`: Command execution and status +- `protection/*`: Safety alerts and acknowledgments +- `logger/*`: Data logging control +- `blcu/*`: Bootloader operations +- `message/*`: System notifications + +### Key Characteristics +- **Real-Time**: WebSocket enables instant updates +- **Bidirectional**: Frontend can send commands to backend +- **Type-Safe**: Structured JSON schemas +- **Scalable**: Multiple frontend clients supported + +**Detailed Specification**: [WebSocket API](./websocket-api.md) + +## Layer 3: Backend Internal Architecture + +### Message Broker System +The backend uses a topic-based message broker to route data between components: + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Transport │ -> │ Broker │ -> │ WebSocket │ +│ Layer │ │ System │ │ Clients │ +└─────────────┘ └─────────────┘ └─────────────┘ + │ │ │ + │ ▼ │ + │ ┌─────────────┐ │ + │ │ Vehicle │ │ + └─────────> │ Management │ <──────────┘ + └─────────────┘ +``` + +### Data Flow + +1. **Incoming Path** (Vehicle → Frontend): + ``` + Vehicle Binary Packet → Transport Decoder → Message Broker → WebSocket JSON + ``` + +2. **Outgoing Path** (Frontend → Vehicle): + ``` + WebSocket JSON → Message Broker → Vehicle Manager → Transport Encoder → Binary Packet + ``` + +## Message Categories and Timing + +### Real-Time Data (High Frequency) +- **Data Packets**: 10-100 Hz per measurement +- **Protection Alerts**: Immediate (fault conditions) +- **Connection Status**: On change + 1 Hz heartbeat +- **Transport**: UDP preferred for speed + +### Command and Control (Low Frequency) +- **Order Commands**: As needed (user-initiated) +- **Logger Control**: Manual operation +- **Configuration Changes**: Manual operation +- **Transport**: TCP required for reliability + +### File Operations (Occasional) +- **Firmware Updates**: Manual operation +- **Configuration Files**: Manual operation +- **Log Downloads**: Manual operation +- **Transport**: TFTP for large data transfers + +## Network Configuration + +### Port Assignments +``` +Service Port Protocol Direction +───────────────────────────────────────────── +TCP Client 50401 TCP Backend → Vehicle +TCP Server 50500 TCP Vehicle → Backend +UDP Data 8000 UDP Bidirectional +TFTP Files 69 UDP Bidirectional +SNTP Time 123 UDP Vehicle → NTP Server +WebSocket API 8080 TCP/HTTP Frontend → Backend +``` + +### IP Address Scheme +- **Backend**: 127.0.0.9 (default, configurable) +- **Vehicle Boards**: Per-board IPs defined in ADJ config + - VCU: 127.0.0.6 + - BCU: 127.0.0.7 + - LCU: 127.0.0.8 + - etc. + +## Protocol Evolution and Versioning + +### ADJ Specification Versioning +- **Current Version**: ADJ v2 +- **Backward Compatibility**: v1 configs automatically migrated +- **Schema Evolution**: Additive changes only for compatibility + +### Protocol Versioning +- **Binary Protocol**: Version embedded in packet structure +- **WebSocket API**: Version negotiation during connection +- **Error Handling**: Graceful degradation for unknown message types + +## Security Considerations + +### Current Implementation +- **Trusted Network**: Assumes secure LAN environment +- **No Authentication**: Direct connection without credentials +- **Input Validation**: All messages validated against schema +- **Rate Limiting**: Prevents message flooding + +### Production Recommendations +- **TLS Encryption**: Secure WebSocket connections (WSS) +- **Authentication**: Token-based client authentication +- **Authorization**: Role-based access control +- **Network Isolation**: Dedicated VLAN for vehicle communication + +## Error Handling and Fault Tolerance + +### Network-Level Errors +- **TCP Disconnection**: Automatic reconnection with exponential backoff +- **UDP Packet Loss**: Statistics tracking, no retransmission +- **WebSocket Disconnect**: Client reconnection with state resync + +### Protocol-Level Errors +- **Malformed Packets**: Graceful error recovery and logging +- **Unknown Message Types**: Warning logged, connection maintained +- **Schema Validation**: Invalid messages rejected with error response + +### Application-Level Errors +- **Board Offline**: Connection status propagated to frontend +- **Protection Faults**: Immediate alert propagation with acknowledgment tracking +- **Command Failures**: Error status returned to frontend client + +## Performance Characteristics + +### Latency +- **Vehicle → Frontend**: < 20ms typical +- **Frontend → Vehicle**: < 50ms typical +- **Protection Alerts**: < 5ms critical path + +### Throughput +- **Data Rate**: 1000+ measurements/second supported +- **Concurrent Clients**: 10+ frontend connections +- **Packet Rate**: 500+ packets/second per board + +### Memory Usage +- **Per Connection**: ~1MB WebSocket client +- **Packet Buffers**: 64KB per transport connection +- **Message Queue**: Bounded queues prevent memory leaks + +## Monitoring and Debugging + +### Built-in Diagnostics +- **Connection Statistics**: Packet counts, error rates, latency +- **Message Logs**: Structured logging with correlation IDs +- **Performance Metrics**: Throughput and latency monitoring + +### Debug Tools +- **Packet Sender**: Test packet generation for development +- **Ethernet View**: Real-time protocol monitoring +- **Network Captures**: PCAP files for detailed analysis + +### Logging Integration +- **Structured Logging**: JSON format with contextual information +- **Log Levels**: Debug, info, warning, error, critical +- **Log Rotation**: Automatic archival of historical data + +## Development Guidelines + +### Adding New Message Types + +1. **Update ADJ Specification**: Define packet structure in JSON +2. **Implement Backend Decoder**: Add packet parsing logic +3. **Define WebSocket Topic**: Specify JSON message format +4. **Update Frontend Types**: TypeScript interfaces for type safety +5. **Add Documentation**: Update protocol specifications + +### Testing Protocol Changes + +1. **Unit Tests**: Individual packet codec validation +2. **Integration Tests**: End-to-end message flow verification +3. **Load Testing**: Performance validation under stress +4. **Compatibility Testing**: Ensure backward compatibility + +### Best Practices + +- **Schema Evolution**: Only additive changes to maintain compatibility +- **Error Handling**: Graceful degradation for unknown message types +- **Documentation**: Keep specifications synchronized with implementation +- **Versioning**: Clear version tracking for all protocol changes + +## Related Documentation + +- [Message Structures](./message-structures.md) - Detailed structure specifications +- [Binary Protocol](./binary-protocol.md) - Vehicle communication details +- [WebSocket API](./websocket-api.md) - Frontend communication details +- [ADJ Specification](../../packet-sender/adj/README.md) - Configuration format +- [Development Guide](../development/DEVELOPMENT.md) - Setup and workflow + +This protocol overview provides the foundation for understanding all communication aspects of the Hyperloop Control Station system. \ No newline at end of file diff --git a/docs/architecture/websocket-api.md b/docs/architecture/websocket-api.md new file mode 100644 index 000000000..0889812ea --- /dev/null +++ b/docs/architecture/websocket-api.md @@ -0,0 +1,421 @@ +# WebSocket API Specification + +This document defines the complete WebSocket API used for communication between the backend and frontend applications (control-station, ethernet-view). + +## Connection + +**Endpoint**: `ws://localhost:8080/ws` (configurable in backend) +**Protocol**: WebSocket over HTTP +**Content-Type**: `application/json` + +## Message Format + +All WebSocket messages follow a consistent structure: + +```json +{ + "topic": "string", + "payload": {} +} +``` + +- `topic`: String identifying the message type and routing +- `payload`: Object containing topic-specific data + +## Topics Reference + +### Data Topics + +#### `data/update` +**Direction**: Backend → Frontend +**Purpose**: Real-time measurement data updates + +```json +{ + "topic": "data/update", + "payload": { + "board_id": 0, + "packet_id": 211, + "packet_name": "vcu_regulator_packet", + "timestamp": "2024-01-15T10:30:45.123Z", + "measurements": { + "measurement_name": { + "value": "number|string|boolean", + "type": "uint8|uint16|uint32|uint64|int8|int16|int32|int64|float32|float64|bool|enum", + "enabled": true, + "units": "string" + } + } + } +} +``` + +#### `data/push` +**Direction**: Backend → Frontend +**Purpose**: Batch data updates for chart displays + +```json +{ + "topic": "data/push", + "payload": { + "board_id": 0, + "measurements": [ + { + "name": "brake_pressure", + "value": 85.2, + "timestamp": "2024-01-15T10:30:45.123Z" + } + ] + } +} +``` + +### Connection Topics + +#### `connection/update` +**Direction**: Backend → Frontend +**Purpose**: Board connection status updates + +```json +{ + "topic": "connection/update", + "payload": { + "board_id": 0, + "board_name": "VCU", + "ip_address": "127.0.0.6", + "connected": true, + "last_seen": "2024-01-15T10:30:45.123Z", + "packets_received": 1250, + "bytes_received": 125000, + "connection_quality": "excellent|good|poor|unknown" + } +} +``` + +#### `connection/push` +**Direction**: Backend → Frontend +**Purpose**: Initial connection state when client connects + +```json +{ + "topic": "connection/push", + "payload": { + "connections": [ + { + "board_id": 0, + "board_name": "VCU", + "connected": true, + "last_seen": "2024-01-15T10:30:45.123Z" + } + ] + } +} +``` + +### Order Topics + +#### `order/send` +**Direction**: Frontend → Backend +**Purpose**: Send command to vehicle board + +```json +{ + "topic": "order/send", + "payload": { + "board_id": 0, + "order_name": "brake_engage", + "parameters": { + "target_pressure": 85.0, + "engage_time": 2000 + } + } +} +``` + +#### `order/state` +**Direction**: Backend → Frontend +**Purpose**: Order execution status updates + +```json +{ + "topic": "order/state", + "payload": { + "board_id": 0, + "order_name": "brake_engage", + "status": "pending|executing|completed|failed", + "timestamp": "2024-01-15T10:30:45.123Z", + "parameters": { + "target_pressure": 85.0 + }, + "error_message": "string" + } +} +``` + +### Logger Topics + +#### `logger/enable` +**Direction**: Frontend → Backend +**Purpose**: Start data logging + +```json +{ + "topic": "logger/enable", + "payload": { + "log_type": "data|protection|state|order", + "measurements": ["brake_pressure", "valve_state"], + "log_rate": 100, + "format": "csv|json", + "filename": "run_001_data.csv" + } +} +``` + +#### `logger/disable` +**Direction**: Frontend → Backend +**Purpose**: Stop data logging + +```json +{ + "topic": "logger/disable", + "payload": { + "log_type": "data|protection|state|order" + } +} +``` + +### Message Topics + +#### `message/update` +**Direction**: Backend → Frontend +**Purpose**: System messages and notifications + +```json +{ + "topic": "message/update", + "payload": { + "id": "unique_message_id", + "level": "info|warning|error", + "source": "backend|vehicle|system", + "title": "Connection Established", + "message": "Successfully connected to VCU board", + "timestamp": "2024-01-15T10:30:45.123Z", + "board_id": 0, + "auto_dismiss": true, + "dismiss_timeout": 5000 + } +} +``` + +### Protection Topics + +#### `protection/alert` +**Direction**: Backend → Frontend +**Purpose**: Safety alerts and fault notifications + +```json +{ + "topic": "protection/alert", + "payload": { + "board_id": 0, + "packet_id": 2, + "severity": "fault|warning|ok", + "protection_type": "above|below|outofbounds|equals|notequals|errorhandler|timeaccumulation", + "measurement_name": "brake_pressure", + "current_value": 105.0, + "threshold_value": 100.0, + "message": "Brake pressure above safe limit", + "timestamp": "2024-01-15T10:30:45.123Z", + "acknowledged": false, + "protection_id": "brake_pressure_high" + } +} +``` + +### BLCU Topics (Bootloader) + +#### `blcu/upload` +**Direction**: Frontend → Backend +**Purpose**: Upload firmware to vehicle board + +```json +{ + "topic": "blcu/upload", + "payload": { + "board_id": 0, + "filename": "firmware_v2.1.bin", + "file_data": "base64_encoded_data", + "checksum": "sha256_hash" + } +} +``` + +#### `blcu/download` +**Direction**: Frontend → Backend +**Purpose**: Download file from vehicle board + +```json +{ + "topic": "blcu/download", + "payload": { + "board_id": 0, + "filename": "config.json", + "destination": "local_config.json" + } +} +``` + +#### `blcu/register` +**Direction**: Backend → Frontend +**Purpose**: BLCU operation status updates + +```json +{ + "topic": "blcu/register", + "payload": { + "board_id": 0, + "operation": "upload|download", + "status": "pending|in_progress|completed|failed", + "progress": 0.65, + "bytes_transferred": 340000, + "total_bytes": 524288, + "estimated_time_remaining": 45, + "error_message": "string" + } +} +``` + +## Implementation Guidelines + +### Client Connection Handling + +1. **Connection Establishment**: Client connects to WebSocket endpoint +2. **Topic Subscription**: Implicit subscription to all relevant topics +3. **Initial State**: Backend sends current state via `push` topics +4. **Real-time Updates**: Backend streams updates via `update` topics + +### Error Handling + +**Invalid Message Format**: +```json +{ + "topic": "error", + "payload": { + "error": "Invalid message format", + "details": "Missing required field: topic", + "original_message": {} + } +} +``` + +**Unknown Topic**: +```json +{ + "topic": "error", + "payload": { + "error": "Unknown topic", + "topic": "invalid/topic", + "available_topics": ["data/update", "connection/update", ...] + } +} +``` + +### Message Ordering + +- **Data Updates**: No ordering guarantee, latest value wins +- **Order Commands**: Processed in receive order +- **Protection Alerts**: Immediate priority processing +- **Connection Updates**: Ordered by timestamp + +### Rate Limiting + +- **Data Updates**: Max 100 Hz per measurement +- **Order Commands**: Max 10 per second per client +- **Logger Operations**: Max 1 per second +- **BLCU Operations**: Max 1 concurrent operation per board + +### Client Reconnection + +1. **Automatic Reconnection**: Clients should implement exponential backoff +2. **State Resynchronization**: Backend sends current state on reconnection +3. **Message Buffer**: Backend may buffer critical messages during disconnect + +## Frontend Integration + +### TypeScript Types + +```typescript +interface WebSocketMessage { + topic: string; + payload: any; +} + +interface DataUpdatePayload { + board_id: number; + packet_id: number; + packet_name: string; + timestamp: string; + measurements: Record; +} + +interface MeasurementValue { + value: number | string | boolean; + type: DataType; + enabled: boolean; + units?: string; +} + +type DataType = 'uint8' | 'uint16' | 'uint32' | 'uint64' | + 'int8' | 'int16' | 'int32' | 'int64' | + 'float32' | 'float64' | 'bool' | 'enum'; +``` + +### Usage Example + +```typescript +const ws = new WebSocket('ws://localhost:8080/ws'); + +ws.onmessage = (event) => { + const message: WebSocketMessage = JSON.parse(event.data); + + switch (message.topic) { + case 'data/update': + handleDataUpdate(message.payload); + break; + case 'connection/update': + handleConnectionUpdate(message.payload); + break; + case 'protection/alert': + handleProtectionAlert(message.payload); + break; + } +}; + +// Send order command +const orderMessage = { + topic: 'order/send', + payload: { + board_id: 0, + order_name: 'brake_engage', + parameters: { target_pressure: 85.0 } + } +}; +ws.send(JSON.stringify(orderMessage)); +``` + +## Security Considerations + +- **No Authentication**: Current implementation assumes trusted network +- **Input Validation**: All incoming messages validated against schema +- **Rate Limiting**: Prevents message flooding attacks +- **Connection Limits**: Maximum concurrent connections enforced +- **Origin Checking**: WebSocket origin validation recommended for production + +## Performance Characteristics + +- **Latency**: Typical message latency < 10ms on local network +- **Throughput**: Supports 1000+ messages/second per connection +- **Memory Usage**: ~1MB per active connection +- **CPU Usage**: Minimal overhead for message routing + +This API specification should be implemented consistently across all frontend applications to ensure proper integration with the backend WebSocket server. \ No newline at end of file diff --git a/docs/development/CROSS_PLATFORM_DEV_SUMMARY.md b/docs/development/CROSS_PLATFORM_DEV_SUMMARY.md new file mode 100644 index 000000000..8e650c2cf --- /dev/null +++ b/docs/development/CROSS_PLATFORM_DEV_SUMMARY.md @@ -0,0 +1,159 @@ +# Cross-Platform Development Scripts Summary + +This document summarizes the cross-platform development improvements made to support Windows, macOS, and Linux development environments. + +## Files Added/Modified + +### New Development Scripts + +1. **`scripts/dev.cmd`** - Windows Batch script + - Native Windows batch file for Command Prompt + - Handles Windows-specific path separators and commands + - Opens services in separate command windows for the `all` command + +2. **`scripts/dev.ps1`** - Windows PowerShell script (Recommended) + - Modern PowerShell script with better error handling + - Colored output and enhanced functionality + - Opens services in separate PowerShell windows for the `all` command + +3. **`scripts/dev-unified.sh`** - Universal cross-platform script + - Enhanced Bash script with comprehensive OS detection + - Supports Unix, Linux, macOS, Windows (Git Bash), WSL, MSYS2, Cygwin + - Intelligent platform-specific behavior adaptation + - Fallback mechanisms for different environments + +4. **`scripts/README.md`** - Comprehensive documentation + - Usage instructions for all platforms + - Platform-specific notes and troubleshooting + - Installation requirements and prerequisites + +### Modified Files + +5. **`scripts/dev.sh`** - Enhanced original script + - Added OS detection (`linux`, `macos`, `windows`) + - Improved Windows support in tmux alternatives + - Better error messages and user guidance + +6. **`DEVELOPMENT.md`** - Updated development guide + - Added platform-specific script instructions + - Included Windows PowerShell and Command Prompt examples + - Added platform-specific notes and troubleshooting + +### New GitHub Workflow + +7. **`.github/workflows/test-dev-scripts.yaml`** - CI/CD testing + - Tests all development scripts across platforms + - Validates functionality on Ubuntu, Windows, and macOS + - Tests both PowerShell and Command Prompt on Windows + +## Platform Support Matrix + +| Platform | Script | Shell | Status | Notes | +|----------|--------|-------|---------|--------| +| **Linux** | `dev.sh` | bash | ✅ Full | Uses tmux for `all` command | +| **macOS** | `dev.sh` | bash | ✅ Full | Uses tmux for `all` command | +| **Windows** | `dev.ps1` | PowerShell | ✅ Full | **Recommended** - Opens separate windows | +| **Windows** | `dev.cmd` | cmd | ✅ Basic | Opens separate windows | +| **Git Bash** | `dev-unified.sh` | bash | ✅ Full | Enhanced Windows compatibility | +| **WSL** | `dev-unified.sh` | bash | ✅ Full | Auto-detects WSL environment | +| **MSYS2** | `dev-unified.sh` | bash | ✅ Full | Windows-native paths | +| **Cygwin** | `dev-unified.sh` | bash | ✅ Basic | Limited testing | + +## Key Features + +### Cross-Platform Compatibility +- **Path Handling**: Automatic Windows/Unix path conversion +- **Command Adaptation**: Platform-specific command variations +- **Shell Detection**: Bash, Zsh, PowerShell, Command Prompt support +- **Environment Detection**: WSL, MSYS2, Cygwin recognition + +### Service Management +- **Unix/Linux/macOS**: tmux sessions with fallback to parallel processes +- **Windows**: Separate windows for each service (PowerShell/cmd) +- **Git Bash/WSL**: Intelligent adaptation based on environment + +### Developer Experience +- **Consistent Commands**: Same command syntax across all platforms +- **Colored Output**: Platform-appropriate colored terminal output +- **Error Handling**: Clear error messages and troubleshooting guidance +- **Dependency Checking**: Validates Go, Node.js, npm availability + +## Usage Examples + +### Windows PowerShell (Recommended) +```powershell +# Setup project +.\scripts\dev.ps1 setup + +# Run all services +.\scripts\dev.ps1 all + +# Run individual service +.\scripts\dev.ps1 backend +``` + +### Windows Command Prompt +```cmd +scripts\dev.cmd setup +scripts\dev.cmd all +scripts\dev.cmd backend +``` + +### Unix/Linux/macOS +```bash +./scripts/dev.sh setup +./scripts/dev.sh all +./scripts/dev.sh backend +``` + +### Git Bash/WSL/MSYS2 +```bash +./scripts/dev-unified.sh setup +./scripts/dev-unified.sh all +./scripts/dev-unified.sh backend +``` + +## GitHub Actions Integration + +The existing release workflow already had excellent cross-platform support: +- ✅ **Linux builds**: Alpine container with static linking +- ✅ **Windows builds**: Native Windows runners with proper PowerShell +- ✅ **macOS builds**: Both Intel (amd64) and Apple Silicon (arm64) + +### New Workflow Added +- **`test-dev-scripts.yaml`**: Tests development scripts across all platforms +- Validates script functionality before merging changes +- Ensures consistent behavior across Windows, macOS, and Linux + +## Troubleshooting + +### Windows Issues +1. **PowerShell Execution Policy**: Run `Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser` +2. **Path Issues**: Use PowerShell script instead of batch file +3. **Missing Dependencies**: Ensure Go, Node.js, npm are in PATH + +### Unix Issues +1. **tmux Not Found**: Install tmux or services will run in parallel processes +2. **Permission Denied**: Run `chmod +x scripts/dev.sh` +3. **Path Issues**: Ensure script is run from project root + +### Cross-Platform Issues +1. **Git Bash**: Use `dev-unified.sh` for better Windows compatibility +2. **WSL**: Automatically detected and handled by unified script +3. **MSYS2**: Use unified script for proper path handling + +## Benefits + +1. **Developer Onboarding**: New developers can start regardless of platform +2. **Team Consistency**: Same workflow across different development environments +3. **CI/CD Confidence**: Scripts tested in automation prevent deployment issues +4. **Maintenance Efficiency**: Single command set for all platforms +5. **Future-Proof**: Easy to extend for new platforms or requirements + +## Future Enhancements + +- **Docker Integration**: Add containerized development option +- **IDE Integration**: VS Code tasks for script integration +- **Progress Indicators**: Enhanced visual feedback during setup +- **Configuration Validation**: Pre-flight checks for environment setup +- **Hot Reload**: File watching for automatic service restarts \ No newline at end of file diff --git a/docs/development/DEVELOPMENT.md b/docs/development/DEVELOPMENT.md new file mode 100644 index 000000000..6d06511bb --- /dev/null +++ b/docs/development/DEVELOPMENT.md @@ -0,0 +1,195 @@ +# Development Setup + +This guide provides multiple ways to set up your development environment for the Hyperloop H10 project. + +## Quick Start (Recommended) + +### Option 1: Cross-Platform Development Scripts + +We provide multiple scripts to work across different platforms: + +#### Unix/Linux/macOS (Bash) +```bash +# Make script executable +chmod +x scripts/dev.sh + +# Install dependencies and setup +./scripts/dev.sh setup + +# Run individual services +./scripts/dev.sh backend # Run backend server +./scripts/dev.sh ethernet # Run ethernet-view +./scripts/dev.sh control # Run control-station +./scripts/dev.sh packet # Run packet-sender + +# Run all services in tmux +./scripts/dev.sh all +``` + +#### Windows (PowerShell - Recommended) +```powershell +# You may need to allow script execution first: +Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser + +# Install dependencies and setup +.\scripts\dev.ps1 setup + +# Run individual services +.\scripts\dev.ps1 backend # Run backend server +.\scripts\dev.ps1 ethernet # Run ethernet-view +.\scripts\dev.ps1 control # Run control-station +.\scripts\dev.ps1 packet # Run packet-sender + +# Run all services in separate windows +.\scripts\dev.ps1 all +``` + +#### Windows (Command Prompt) +```cmd +REM Install dependencies and setup +scripts\dev.cmd setup + +REM Run individual services +scripts\dev.cmd backend REM Run backend server +scripts\dev.cmd ethernet REM Run ethernet-view +scripts\dev.cmd control REM Run control-station +scripts\dev.cmd packet REM Run packet-sender + +REM Run all services in separate windows +scripts\dev.cmd all +``` + +#### Universal Script (Git Bash/WSL/MSYS2) +For Windows users with Git Bash, WSL, or MSYS2: +```bash +# Enhanced cross-platform script with better Windows support +chmod +x scripts/dev-unified.sh +./scripts/dev-unified.sh setup +./scripts/dev-unified.sh all +``` + +### Option 2: Using Docker Compose + +```bash +# Run all services +docker-compose -f docker-compose.dev.yml up + +# Run specific service +docker-compose -f docker-compose.dev.yml up backend +``` + +### Option 3: Manual Setup + +```bash +# 1. Build common-front first (required) +cd common-front +npm install +npm run build + +# 2. Install frontend dependencies +cd ../ethernet-view +npm install + +cd ../control-station +npm install + +# 3. Run services +cd backend/cmd && go run . # Terminal 1 +cd ethernet-view && npm run dev # Terminal 2 +cd control-station && npm run dev # Terminal 3 +cd packet-sender && go run . # Terminal 4 +``` + +### Option 4: Using Nix (Advanced) + +For developers who prefer Nix for reproducible environments: + +```bash +# Pure shell with Fish +nix-shell + +# Or with your existing shell config +nix-shell -A impure +``` + +This provides a fully reproducible development environment with all dependencies. + +## Platform-Specific Notes + +### Windows +- **PowerShell Script** (`dev.ps1`) is recommended over Command Prompt (`dev.cmd`) for better functionality +- If you encounter execution policy issues: `Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser` +- The `all` command opens each service in separate windows instead of using tmux +- For Unix-like experience on Windows, use **Git Bash**, **WSL**, or **MSYS2** with the unified script + +### Unix/Linux/macOS +- The `all` command uses **tmux** for session management (install with your package manager) +- If tmux is unavailable, services run in parallel background processes +- Use `Ctrl+C` to stop all services when running in parallel mode + +### Git Bash/WSL/MSYS2 +- Use the `dev-unified.sh` script for enhanced Windows compatibility +- Automatically detects WSL and adjusts behavior accordingly +- Provides better path handling for Windows environments + +## Prerequisites + +- Go 1.21+ +- Node.js 18+ +- npm +- libpcap (for packet capture) + - macOS: `brew install libpcap` + - Ubuntu: `sudo apt-get install libpcap-dev` + - Windows: Install WinPcap or Npcap + +## Service Ports + +- Backend: 8080 +- Control Station: 5173 +- Ethernet View: 5174 + +## Common Tasks + +### Run Tests +```bash +./scripts/dev.sh test +# or manually: +cd backend && go test -v ./... +``` + +### Build All Components +```bash +make all +``` + +### Update Dependencies +```bash +# Go dependencies +cd backend && go mod tidy + +# Node dependencies +cd common-front && npm update +cd control-station && npm update +cd ethernet-view && npm update +``` + +## Troubleshooting + +1. **Permission denied on macOS/Linux for packet capture**: + ```bash + sudo setcap cap_net_raw+ep $(which go) + ``` + +2. **Common-front not found errors**: + Make sure to build common-front first: + ```bash + cd common-front && npm install && npm run build + ``` + +3. **Port already in use**: + Check and kill processes using the ports: + ```bash + lsof -i :8080 # backend + lsof -i :5173 # control-station + lsof -i :5174 # ethernet-view + ``` \ No newline at end of file diff --git a/docs/development/scripts.md b/docs/development/scripts.md new file mode 100644 index 000000000..f8ee607c2 --- /dev/null +++ b/docs/development/scripts.md @@ -0,0 +1,149 @@ +# Development Scripts + +This directory contains cross-platform development scripts for the Hyperloop H10 project. + +## Available Scripts + +### Unix/Linux/macOS +- `dev.sh` - Main development script with OS detection + +### Windows +- `dev.cmd` - Windows Batch script +- `dev.ps1` - PowerShell script (recommended for Windows) + +## Prerequisites + +Before using any script, ensure you have the following installed: + +- **Go** (1.19+) +- **Node.js** (18+) +- **npm** (comes with Node.js) + +### Additional for Unix systems: +- **tmux** (for running all services simultaneously) + +## Usage + +### Unix/Linux/macOS + +```bash +# Make script executable +chmod +x ../../scripts/dev.sh + +# Run commands (from project root) +./scripts/dev.sh setup # Install dependencies +./scripts/dev.sh backend # Run backend server +./scripts/dev.sh ethernet # Run ethernet-view +./scripts/dev.sh control # Run control-station +./scripts/dev.sh packet # Run packet-sender +./scripts/dev.sh all # Run all services (requires tmux) +./scripts/dev.sh test # Run tests +./scripts/dev.sh build # Build all components +``` + +### Windows Command Prompt + +```cmd +REM Run commands +scripts\dev.cmd setup REM Install dependencies +scripts\dev.cmd backend REM Run backend server +scripts\dev.cmd ethernet REM Run ethernet-view +scripts\dev.cmd control REM Run control-station +scripts\dev.cmd packet REM Run packet-sender +scripts\dev.cmd all REM Run all services in separate windows +scripts\dev.cmd test REM Run tests +scripts\dev.cmd build REM Build all components +``` + +### Windows PowerShell + +```powershell +# You may need to allow script execution first: +Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser + +# Run commands +.\scripts\dev.ps1 setup # Install dependencies +.\scripts\dev.ps1 backend # Run backend server +.\scripts\dev.ps1 ethernet # Run ethernet-view +.\scripts\dev.ps1 control # Run control-station +.\scripts\dev.ps1 packet # Run packet-sender +.\scripts\dev.ps1 all # Run all services in separate windows +.\scripts\dev.ps1 test # Run tests +.\scripts\dev.ps1 build # Build all components +``` + +## Commands Explained + +### `setup` +Installs all project dependencies: +- Installs npm packages for `common-front`, `ethernet-view`, and `control-station` +- Downloads Go module dependencies for `backend` and `packet-sender` +- Builds the `common-front` library + +### `backend` +Starts the Go backend server in development mode. + +### `ethernet` +Starts the ethernet-view frontend development server (typically on port 5174). + +### `control` +Starts the control-station frontend development server (typically on port 5173). + +### `packet` +Starts the packet-sender utility. + +### `all` +Runs all services simultaneously: +- **Unix/Linux/macOS**: Uses tmux to create a session with multiple windows +- **Windows**: Opens each service in a separate command/PowerShell window + +### `test` +Runs all project tests (currently backend Go tests). + +### `build` +Builds all project components for production. + +## Platform-Specific Notes + +### Windows +- The `all` command opens separate windows for each service instead of using tmux +- PowerShell script (`dev.ps1`) is recommended over batch script (`dev.cmd`) for better functionality +- If you encounter execution policy issues with PowerShell, run: `Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser` + +### Unix/Linux/macOS +- The `all` command requires tmux to be installed +- If tmux is not available, run services individually in separate terminals +- The script automatically detects the operating system + +## Troubleshooting + +### "Command not found" errors +Ensure Go, Node.js, and npm are installed and available in your PATH. + +### tmux not found (Unix systems) +Install tmux or run services individually: +```bash +# Install tmux on Ubuntu/Debian +sudo apt install tmux + +# Install tmux on macOS with Homebrew +brew install tmux + +# Or run services individually in separate terminals +./scripts/dev.sh backend # Terminal 1 +./scripts/dev.sh ethernet # Terminal 2 +./scripts/dev.sh control # Terminal 3 +./scripts/dev.sh packet # Terminal 4 +``` + +### PowerShell execution policy (Windows) +If you get an execution policy error, run PowerShell as Administrator and execute: +```powershell +Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser +``` + +### Port conflicts +If you encounter port conflicts, check that no other services are running on the default ports: +- Backend: 8080 (configurable) +- Ethernet-view: 5174 +- Control-station: 5173 \ No newline at end of file diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md new file mode 100644 index 000000000..0312ec79f --- /dev/null +++ b/docs/guides/getting-started.md @@ -0,0 +1,219 @@ +# Getting Started with Hyperloop H10 Control Station + +This guide will help you get up and running with the Hyperloop H10 Control Station, whether you're a developer, operator, or contributor. + +## What is the H10 Control Station? + +The Hyperloop H10 Control Station is a real-time monitoring and control system for Hyperloop UPV's competition pod. It provides: + +- **Real-time monitoring** of pod sensors and systems +- **Control interface** for pod operations +- **Data logging** and analysis capabilities +- **Network debugging** tools for development + +## Quick Setup + +### Prerequisites + +Before you begin, ensure you have: + +- **Go 1.21+** - [Download here](https://golang.org/doc/install) +- **Node.js 18+** - [Download here](https://nodejs.org/) +- **Git** - For version control + +### Platform-Specific Requirements + +#### Windows +- **PowerShell 5.1+** (recommended) or Command Prompt +- **Visual C++ Build Tools** (for native modules) + +#### macOS +- **Homebrew** (recommended): `brew install libpcap` +- **Xcode Command Line Tools**: `xcode-select --install` + +#### Linux +- **libpcap development headers**: + - Ubuntu/Debian: `sudo apt install libpcap-dev` + - CentOS/RHEL: `sudo yum install libpcap-devel` + +### Installation + +Choose your platform and follow the appropriate steps: + +#### Windows (PowerShell - Recommended) +```powershell +# Clone the repository +git clone https://github.com/HyperloopUPV-H8/software.git +cd software + +# Allow script execution +Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser + +# Setup dependencies +.\scripts\dev.ps1 setup + +# Run all services +.\scripts\dev.ps1 all +``` + +#### Windows (Command Prompt) +```cmd +REM Clone the repository +git clone https://github.com/HyperloopUPV-H8/software.git +cd software + +REM Setup dependencies +scripts\dev.cmd setup + +REM Run all services +scripts\dev.cmd all +``` + +#### macOS/Linux +```bash +# Clone the repository +git clone https://github.com/HyperloopUPV-H8/software.git +cd software + +# Make scripts executable +chmod +x scripts/dev.sh + +# Setup dependencies +./scripts/dev.sh setup + +# Run all services +./scripts/dev.sh all +``` + +## What Happens During Setup + +The setup process will: + +1. **Install frontend dependencies** for all React applications +2. **Build the common frontend library** used by all applications +3. **Download Go module dependencies** for backend services +4. **Verify all tools** are properly installed + +## Running the Applications + +After setup, you can access: + +- **Control Station**: http://localhost:5173 - Main pod control interface +- **Ethernet View**: http://localhost:5174 - Network monitoring and debugging +- **Backend API**: http://localhost:8080 - Backend services and WebSocket + +### Individual Services + +You can also run services individually: + +```bash +# Backend only +./scripts/dev.sh backend + +# Control station only +./scripts/dev.sh control + +# Ethernet view only +./scripts/dev.sh ethernet + +# Packet sender (testing tool) +./scripts/dev.sh packet +``` + +## Understanding the Interface + +### Control Station +- **Main Dashboard**: Pod status and sensor readings +- **Control Panel**: Send commands to pod systems +- **Data Visualization**: Real-time charts and graphs +- **Camera Views**: Live video feeds from pod cameras + +### Ethernet View +- **Packet Monitor**: Real-time network traffic analysis +- **Board Communication**: Monitor communication with individual boards +- **Data Tables**: Structured view of incoming sensor data +- **Debugging Tools**: Network troubleshooting utilities + +## Configuration + +### Backend Configuration +The main configuration file is located at `backend/cmd/config.toml`: + +```toml +[network] +# Your machine's IP address (must match ADJ specifications) +backend_ip = "192.168.0.9" + +[vehicle.boards] +# Enable/disable specific boards +VCU = true +PCU = true +TCU = true +# ... other boards +``` + +### ADJ Specifications +ADJ (JSON-based specifications) define: +- Board configurations and IDs +- Measurement definitions with units +- Packet structures and communication protocols +- Command (order) definitions + +Located in: `backend/cmd/adj/boards/[BOARD_NAME]/` + +## Next Steps + +### For Developers +1. Read the [Development Setup](../development/DEVELOPMENT.md) guide +2. Explore the [Architecture Overview](../architecture/README.md) +3. Check out [Contributing Guidelines](../../CONTRIBUTING.md) + +### For Operators +1. Review [Configuration Guide](configuration.md) +2. Learn about [Testing Procedures](testing.md) +3. Understand [Deployment Process](deployment.md) + +### For Contributors +1. Read [Contributing Guidelines](../../CONTRIBUTING.md) +2. Explore existing [GitHub Issues](https://github.com/HyperloopUPV-H8/software/issues) +3. Join the development discussion + +## Troubleshooting + +### Common Issues + +**Services won't start** +- Check that all prerequisites are installed +- Verify no other services are using the ports (5173, 5174, 8080) +- Run `./scripts/dev.sh setup` again to ensure proper installation + +**Build failures** +- Ensure Go and Node.js versions meet requirements +- Check internet connectivity for downloading dependencies +- Review error messages for specific missing packages + +**Permission errors (Unix)** +- Make sure scripts are executable: `chmod +x scripts/dev.sh` +- Check file permissions in the project directory + +**PowerShell execution errors (Windows)** +- Run: `Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser` +- Use "Run as Administrator" if needed + +### Getting Help + +- **Documentation**: Check the [troubleshooting guide](../troubleshooting/common-issues.md) +- **Issues**: Search and create [GitHub Issues](https://github.com/HyperloopUPV-H8/software/issues) +- **Community**: Join the development discussion + +## Success Indicators + +You'll know everything is working when: + +✅ All services start without errors +✅ Control Station loads at http://localhost:5173 +✅ Ethernet View loads at http://localhost:5174 +✅ Backend API responds at http://localhost:8080 +✅ No console errors in browser developer tools + +Welcome to the Hyperloop H10 development community! 🚄 \ No newline at end of file diff --git a/docs/troubleshooting/BLCU_FIX_SUMMARY.md b/docs/troubleshooting/BLCU_FIX_SUMMARY.md new file mode 100644 index 000000000..8f4fe5b3e --- /dev/null +++ b/docs/troubleshooting/BLCU_FIX_SUMMARY.md @@ -0,0 +1,61 @@ +# BLCU (Boot Loader Control Unit) Fix Summary + +## Issues Fixed + +1. **BLCU Board Not Registered**: The BLCU board was never registered with the vehicle, causing all BLCU operations to fail silently. + +2. **Frontend/Backend Data Format Mismatch**: + - Frontend sends `board` and `file` (base64 encoded) fields + - Backend expected `Board` and `Data` (raw bytes) fields + +3. **Topic Name Mismatch**: The vehicle was listening for the wrong topic names. + +4. **Response Format**: Backend wasn't sending proper response format expected by frontend. + +## Changes Made + +### 1. Updated Request Structures (`pkg/broker/topics/blcu/upload.go`) +- Changed `UploadRequest` to accept `file` field with base64 encoded data +- Added `UploadRequestInternal` for internal processing with decoded bytes +- Added base64 decoding in the handler + +### 2. Fixed Response Handling (`pkg/broker/topics/blcu/download.go` & `upload.go`) +- Updated `Push` methods to send proper JSON responses with: + - `percentage`: Progress indicator (100 for success, 0 for failure) + - `failure`: Boolean flag + - `file`: Downloaded data (for download responses) + +### 3. Registered BLCU Board (`cmd/main.go`) +- Added BLCU board registration after vehicle setup +- Uses BLCU IP from ADJ configuration + +### 4. Fixed Event Handling (`pkg/boards/blcu.go`) +- Updated event constants to use proper types +- Fixed notification type assertions (added pointer types) + +### 5. Updated Vehicle UserPush (`pkg/vehicle/vehicle.go`) +- Fixed topic names to match request topics +- Added proper board existence checks +- Handle both UploadRequest and UploadRequestInternal types + +## Testing + +Created comprehensive tests in: +- `pkg/boards/blcu_integration_test.go` - Integration tests +- `pkg/boards/blcu_simple_test.go` - Simple unit tests + +## How It Works Now + +1. Frontend sends WebSocket message to `blcu/download` or `blcu/upload` topic +2. Broker topic handler processes the message and calls `UserPush` +3. Vehicle receives the push and notifies the BLCU board +4. BLCU board executes TFTP operations +5. BLCU board sends success/failure response back through broker +6. Frontend receives properly formatted response + +## Remaining Considerations + +- TFTP server must be running and accessible at the BLCU IP address +- The board name in the request must match a valid board in the ADJ configuration +- File data from frontend must be base64 encoded +- Progress updates during transfer are not yet implemented (TODO comments in code) \ No newline at end of file diff --git a/docs/troubleshooting/common-issues.md b/docs/troubleshooting/common-issues.md new file mode 100644 index 000000000..4363181ec --- /dev/null +++ b/docs/troubleshooting/common-issues.md @@ -0,0 +1,392 @@ +# Control Station Troubleshooting Guide + +## Quick Diagnostics + +### System Health Check + +Run this checklist when experiencing issues: + +1. **Backend Status** + ```bash + # Check if backend is running + ps aux | grep backend + + # Check backend logs + tail -f trace.json | jq + + # Verify network interfaces + ip addr show | grep 192.168.0.9 + ``` + +2. **Network Connectivity** + ```bash + # Test board connectivity + ping -c 3 192.168.1.4 # Replace with board IP + + # Check active connections + netstat -an | grep -E "504[0-9]" + + # Monitor network traffic + sudo tcpdump -i any -n host 192.168.1.4 + ``` + +3. **Frontend Connection** + - Open browser DevTools (F12) + - Go to Network tab → WS + - Check WebSocket connection status + - Look for error messages in Console + +## Common Issues and Solutions + +### 1. No Data from Boards + +#### Symptoms +- Empty dashboard +- No measurements updating +- Connection indicator red + +#### Diagnosis +```bash +# Check if board is configured +grep -A 5 "vehicle" config.toml + +# Verify ADJ board definition exists +ls adj/boards/[BOARD_NAME]/ + +# Check network connectivity +ping 192.168.1.[BOARD_IP] +``` + +#### Solutions + +**Solution A: Board not in config.toml** +```toml +# Edit config.toml +[vehicle] +boards = ["LCU", "HVSCU", "BMSL"] # Add missing board +``` + +**Solution B: Network configuration issue** +```bash +# Set correct IP on backend machine +sudo ip addr add 192.168.0.9/24 dev eth0 + +# Verify routing +ip route | grep 192.168 +``` + +**Solution C: Board firmware issue** +- Check board has correct backend IP (192.168.0.9) +- Verify board is sending to correct ports (50400/50500) +- Use Wireshark to capture packets + +### 2. Orders Not Executing + +#### Symptoms +- Orders sent but no response +- Board doesn't react to commands +- No error messages + +#### Diagnosis +```bash +# Check order exists in ADJ +cat adj/boards/[BOARD]/orders.json | jq '.[] | select(.id == [ORDER_ID])' + +# Monitor order flow +tail -f trace.json | jq 'select(.topic == "order/send")' +``` + +#### Solutions + +**Solution A: Invalid order ID** +- Verify order ID exists in board's orders.json +- Check for typos in order name +- Ensure all required fields are provided + +**Solution B: WebSocket message format** +```javascript +// Correct format +{ + "topic": "order/send", + "payload": { + "id": 9995, + "fields": { + "ldu_id": { "value": 1, "type": "uint8" } + } + } +} +``` + +**Solution C: Board not processing orders** +- Check board TCP connection is established +- Verify board firmware handles the order ID +- Look for ACK messages in logs + +### 3. WebSocket Disconnections + +#### Symptoms +- "Disconnected" message in UI +- Data stops updating +- Need to refresh page frequently + +#### Diagnosis +```javascript +// In browser console +// Check WebSocket state +console.log(ws.readyState); // Should be 1 (OPEN) +``` + +#### Solutions + +**Solution A: Backend crashed** +```bash +# Check backend process +ps aux | grep backend + +# Restart if needed +./scripts/dev.sh backend +``` + +**Solution B: Network interruption** +- Check for proxy/firewall blocking WebSocket +- Verify no rate limiting on network +- Try different browser/disable extensions + +**Solution C: Frontend error** +- Check browser console for JavaScript errors +- Clear browser cache and reload +- Update to latest frontend version + +### 4. BLCU Upload/Download Fails + +#### Symptoms +- Firmware upload stuck at 0% +- "Transfer failed" error +- Timeout during TFTP operation + +#### Diagnosis +```bash +# Test TFTP connectivity +tftp 192.168.1.254 +> status +> quit + +# Check BLCU orders in logs +tail -f trace.json | jq 'select(.board == "BLCU")' +``` + +#### Solutions + +**Solution A: BLCU not responding** +- Verify BLCU IP address is correct +- Check BLCU is in bootloader mode +- Power cycle the BLCU board + +**Solution B: TFTP timeout** +```go +// Increase timeout in config +[blcu] +timeout_ms = 10000 # Increase from 5000 +``` + +**Solution C: File too large** +- Check file size is within limits +- Split large files if necessary +- Verify adequate network bandwidth + +### 5. High CPU/Memory Usage + +#### Symptoms +- System sluggish +- Fan running constantly +- Backend using >50% CPU + +#### Diagnosis +```bash +# Monitor resource usage +htop + +# Check goroutine count +curl http://localhost:4040/debug/pprof/goroutine?debug=1 | grep goroutine | wc -l + +# Profile CPU usage +go tool pprof http://localhost:4040/debug/pprof/profile?seconds=30 +``` + +#### Solutions + +**Solution A: Too many reconnection attempts** +- Check for boards that are offline +- Increase reconnection backoff +- Remove offline boards from config + +**Solution B: Memory leak** +- Restart backend periodically +- Update to latest version +- Report issue with heap profile + +**Solution C: Excessive logging** +```bash +# Reduce log verbosity +./backend -trace=warn # Instead of debug/trace +``` + +## Advanced Debugging + +### Packet Analysis + +**Capture all pod traffic**: +```bash +sudo tcpdump -i any -w capture.pcap 'net 192.168.0.0/16' +``` + +**Analyze with Wireshark**: +1. Open capture.pcap in Wireshark +2. Apply filter: `ip.addr == 192.168.1.4` +3. Look for: + - TCP RST packets (connection issues) + - Retransmissions (network problems) + - Malformed packets (firmware bugs) + +### Backend Debug Mode + +**Enable verbose logging**: +```bash +./backend -trace=trace -log=detailed.json +``` + +**Enable CPU profiling**: +```bash +./backend -cpuprofile=cpu.prof +go tool pprof -http=:8081 cpu.prof +``` + +**Memory profiling**: +```bash +curl http://localhost:4040/debug/pprof/heap > heap.prof +go tool pprof -http=:8082 heap.prof +``` + +### Frontend Debugging + +**Enable React DevTools**: +1. Install React Developer Tools extension +2. Open Components tab in DevTools +3. Search for problematic component +4. Check props and state + +**Network debugging**: +```javascript +// In browser console +// Log all WebSocket messages +const originalSend = WebSocket.prototype.send; +WebSocket.prototype.send = function(data) { + console.log('WS Send:', data); + return originalSend.call(this, data); +}; +``` + +## Error Messages Reference + +### Backend Errors + +| Error | Meaning | Solution | +|-------|---------|----------| +| `failed to obtain sniffer source` | Cannot access network interface | Run with sudo or fix permissions | +| `backend address not found in any device` | Wrong network configuration | Set correct IP on network interface | +| `failed to compile bpf filter` | Invalid packet filter | Check filter syntax | +| `Backend is already running` | PID file exists | Remove `/tmp/backendPid` or kill existing process | + +### Frontend Errors + +| Error | Meaning | Solution | +|-------|---------|----------| +| `WebSocket connection failed` | Cannot reach backend | Check backend is running and accessible | +| `Invalid message format` | Malformed WebSocket message | Check message structure matches API | +| `Unknown topic` | Topic not recognized | Verify topic name is correct | + +### Board Communication Errors + +| Error | Meaning | Solution | +|-------|---------|----------| +| `Connection refused` | Board not listening | Check board IP and port | +| `Connection timeout` | Network unreachable | Verify network path to board | +| `Packet decode error` | Malformed packet | Check ADJ matches firmware | +| `Invalid packet ID` | Unknown packet type | Update ADJ configuration | + +## Performance Tuning + +### Network Optimization + +```toml +# config.toml +[tcp] +connection_timeout = 5000 # Increase for slow networks +keep_alive = 1000 # Decrease for faster detection +backoff_multiplier = 1.5 # Adjust reconnection rate + +[transport] +propagate_fault = true # Enable fast fault propagation +``` + +### Resource Limits + +```bash +# Increase file descriptor limit +ulimit -n 4096 + +# Increase network buffers +sudo sysctl -w net.core.rmem_max=134217728 +sudo sysctl -w net.core.wmem_max=134217728 +``` + +## Getting Help + +### Before Asking for Help + +1. **Collect information**: + ```bash + # System info + uname -a + go version + node --version + + # Recent logs + tail -n 1000 trace.json > debug_logs.json + + # Configuration + cp config.toml debug_config.toml + ``` + +2. **Try basic fixes**: + - Restart backend and frontend + - Clear browser cache + - Update to latest version + - Check network connectivity + +3. **Document the issue**: + - What were you trying to do? + - What did you expect to happen? + - What actually happened? + - Can you reproduce it? + +### Where to Get Help + +1. **GitHub Issues**: For bugs and feature requests +2. **Team Discord**: For quick questions +3. **Documentation**: Check if already documented +4. **Code Comments**: Often contain helpful context + +### Creating Bug Reports + +Include: +- System information +- Steps to reproduce +- Expected vs actual behavior +- Relevant logs +- Screenshots if UI issue +- Configuration files (sanitized) + +--- + +*For architecture details and how the system works, see the [Complete Architecture Guide](../../CONTROL_STATION_COMPLETE_ARCHITECTURE.md).* \ No newline at end of file diff --git a/electron-app/BUILD.md b/electron-app/BUILD.md deleted file mode 100644 index 855ba3ff2..000000000 --- a/electron-app/BUILD.md +++ /dev/null @@ -1,71 +0,0 @@ -# Hyperloop Control Station Build System - -The project uses a unified, modular build script (`electron-app/build.mjs`) to handle building the backend (Go), packet sender (Rust), and frontends (React/Vite) for the Electron application. - -## Prerequisites - -- **Node.js** & **pnpm** -- **Go** (1.21+) -- **Rust/Cargo** (for Packet Sender) - -## Basic Usage - -Run the build script from the `electron-app` directory (or via npm scripts). - -```sh -# Build EVERYTHING (Backend, Packet Sender, Frontends) -pnpm build - -# OR -node build.mjs -``` - -## Configuration - -The build configuration is defined in `electron-app/build.mjs` within the `CONFIG` object. - -## Build Specific Components - -You can build individual components by passing their flag. - -```sh -# Build only the Backend -node build.mjs --backend - -# Build only the Testing View -node build.mjs --testing-view - -# Build only the Packet Sender -node build.mjs --packet-sender -``` - -## Platform Targeting - -By default, the script builds for all defined platforms (Windows, Linux, macOS). You can limit this using flags. - -```sh -# Build backend for Windows only -node build.mjs --backend --win - -# Build everything for Linux -node build.mjs --linux -``` - -## Advanced: Overwriting Commands - -The build script allows you to override configuration properties on the fly. This is useful for CI pipelines where you might want to use different build commands or flags. - -**Syntax**: `--[target].[property]="value"` - -### Examples - -```sh -# Use a custom build command for the backend: -node build.mjs --backend --backend.commands="pnpm run build:prod --" - -# Change the output directory -node build.mjs --backend --backend.output="./dist/bin" - -# Pass arguments to the underlying tools (passes -v) -node build.mjs --backend -- -v -``` diff --git a/electron-app/README.md b/electron-app/README.md index 854930745..4eecf6b92 100644 --- a/electron-app/README.md +++ b/electron-app/README.md @@ -22,15 +22,16 @@ When running in development mode (unpackaged), the application creates temporary - `config.toml.backup-{timestamp}` - Automatic backup files created when importing a configuration. These timestamped backups help recover previous configurations if needed. -- `binaries/` - Directory containing compiled backend executables for your platform. These are generated during the build process, when running `pnpm run build`. +- `binaries/` - Directory containing compiled backend executables for your platform. These are generated during the build process, when running `npm run build`. -- `renderer/` - Directory containing built frontend views (control-station, ethernet-view). These are generated during the build process, when running `pnpm run build`. +- `renderer/` - Directory containing built frontend views (control-station, ethernet-view). These are generated during the build process, when running `npm run build`. -- `dist/` - Build output directory containing compiled and packaged application files. Generated during build and distribution processes, when running `pnpm run dist`. +- `dist/` - Build output directory containing compiled and packaged application files. Generated during build and distribution processes, when running `npm run dist`. **Note**: These files and directories are created in the `electron-app/` directory root during development. In production (packaged) mode: - **Configuration and Logs**: Stored in `{UserConfigDir}/hyperloop-control-station/` (using Go's `os.UserConfigDir()`) + - Config files and backups: `{UserConfigDir}/hyperloop-control-station/configs/` - Trace/log files: `{UserConfigDir}/hyperloop-control-station/trace-*.json` @@ -54,24 +55,24 @@ Typical locations: ``` # Install dependencies -pnpm install +npm install # Build backend and frontends -pnpm run build +npm run build -# Run in development mode (you MUST run `pnpm run build` BEFORE!) -pnpm start +# Run in development mode (you MUST run `npm run build` BEFORE!) +npm start ``` ## Build for production This script creates distributables and executables. -**Note**: You must run `pnpm run build` for this script to work correctly. +**Note**: You must run `npm run build` for this script to work correctly. ``` -pnpm run dist:win # Windows -pnpm run dist:mac # macOS -pnpm run dist:linux # Linux +npm run dist:win # Windows +npm run dist:mac # macOS +npm run dist:linux # Linux ``` ### macOS Requirements @@ -85,14 +86,14 @@ sudo ipconfig set en0 INFORM 127.0.0.9 ## Available Scripts ``` -- `pnpm run build` - Build all frontend views and backend -- `pnpm start` - Run application in development mode -- `pnpm run dist` - Build production executable -- `pnpm test` - Run tests +- `npm run build` - Build all frontend views and backend +- `npm start` - Run application in development mode +- `npm run dist` - Build production executable +- `npm test` - Run tests ...and many custom variations (see package.json) -# Only works and makes sense after running `pnpm run dist` -- `pnpm run asar:{platform}` - Shows .asar application package content for [win, linux, mac] platforms +# Only works and makes sense after running `npm run dist` +- `npm run asar:{platform}` - Shows .asar application package content for [win, linux, mac] platforms ``` ## Architecture diff --git a/electron-app/app-update.yml b/electron-app/app-update.yml deleted file mode 100644 index 27ef92e02..000000000 --- a/electron-app/app-update.yml +++ /dev/null @@ -1,4 +0,0 @@ -provider: github -owner: Hyperloop-UPV -repo: software -updaterCacheDirName: hyperloop-control-station/updater diff --git a/electron-app/build.mjs b/electron-app/build.mjs index f83964a19..17fa0dfe2 100644 --- a/electron-app/build.mjs +++ b/electron-app/build.mjs @@ -1,280 +1,285 @@ #!/usr/bin/env node /** * @file build.mjs - * @description Modular build script for Hyperloop Control Station + * @description Build script for the Hyperloop Control Station Electron application. + * Handles building backend binaries, frontend applications, and managing build artifacts. */ import { execSync } from "child_process"; -import { copyFileSync, cpSync, existsSync, mkdirSync, rmSync } from "fs"; -import { dirname, join } from "path"; +import { mkdirSync, rmSync, cpSync } from "fs"; +import { join, dirname } from "path"; import { fileURLToPath } from "url"; import { logger } from "./src/utils/logger.js"; +import { colors } from "./src/utils/colors.js"; -// --- Configuration --- - +// Get current directory path const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = dirname(__dirname); - -const CONFIG = { - backend: { - type: "go", - path: join(ROOT, "backend"), // Root of backend (where package.json is) - output: join(__dirname, "binaries"), - commands: ["pnpm run build:ci"], - platforms: [ - { - id: "win64", - goos: "windows", - goarch: "amd64", - ext: ".exe", - tags: ["win", "windows"], - }, - { - id: "linux64", - goos: "linux", - goarch: "amd64", - ext: "", - tags: ["linux"], - }, - { - id: "mac64", - goos: "darwin", - goarch: "amd64", - ext: "", - tags: ["mac", "macos"], - }, - { - id: "macArm", - goos: "darwin", - goarch: "arm64", - ext: "", - tags: ["mac", "macos"], - }, - ], - }, - "packet-sender": { - type: "rust", - path: join(ROOT, "packet-sender"), - output: join(__dirname, "binaries"), - commands: ["pnpm run build"], - binaryPath: "target/release/packet-sender", - platforms: [ - { id: "win64", ext: ".exe", tags: ["win", "windows"] }, - { id: "linux64", ext: "", tags: ["linux"] }, - { id: "mac64", ext: "", tags: ["mac", "macos"] }, - ], - }, - "testing-view": { - type: "frontend", - path: join(ROOT, "frontend/testing-view"), - dest: join(__dirname, "renderer/testing-view"), - commands: [ - "pnpm --filter testing-view install --frozen-lockfile", - "pnpm run build", - ], - }, - "competition-view": { - type: "frontend", - path: join(ROOT, "frontend/competition-view"), - dest: join(__dirname, "renderer/competition-view"), - commands: [ - "pnpm --filter competition-view install --frozen-lockfile", - "pnpm run build", - ], - optional: true, - }, -}; +// Get project root directory (parent of electron-app) +const root = dirname(__dirname); -// --- Helpers --- - -const run = (cmd, cwd, env = {}) => { +/** + * Executes a shell command synchronously with error handling. + * @param {string} cmd - The command to execute. + * @param {string} [cwd=root] - Working directory for the command. + * @param {Object} [env={}] - Additional environment variables to set. + * @returns {boolean} True if command succeeded, false otherwise. + * @example + * const success = run("npm install", "/path/to/project"); + * run("go build", backendDir, { GOOS: "windows", GOARCH: "amd64" }); + */ +const run = (cmd, cwd = root, env = {}) => { try { - const finalEnv = { ...process.env, ...env }; - execSync(cmd, { cwd, stdio: "inherit", shell: true, env: finalEnv }); + // Execute command with inherited stdio (shows output) + execSync(cmd, { + cwd, + stdio: "inherit", + shell: true, + // Merge process environment with provided env vars + env: { ...process.env, ...env }, + }); return true; } catch (e) { - logger.error(`Command failed: ${cmd}`); - return false; - } -}; - -const buildBackend = (config, requestedPlatforms, extraArgs = "") => { - logger.info("Building Backend (Go)..."); - mkdirSync(config.output, { recursive: true }); - - const targets = config.platforms.filter((p) => { - if ( - !requestedPlatforms || - requestedPlatforms.length === 0 || - requestedPlatforms.includes("all") - ) - return true; - return p.tags.some((tag) => requestedPlatforms.includes(tag)); - }); - - if (targets.length === 0) { - logger.error( - `No matching platforms found for: ${requestedPlatforms.join(", ")}` + // Log warning if command fails (may need cross-compile tools) + logger.warning( + `${cmd.split(" ")[0]} failed (may need cross-compile tools)` ); return false; } - - let success = true; - for (const p of targets) { - const filename = `backend-${p.goos}-${p.goarch}${p.ext}`; - logger.step(`Building ${p.goos}/${p.goarch}...`); - - for (const cmd of config.commands) { - // cmd is like "pnpm run build:ci --" - // We append the output flag and target directory - const buildCmd = `${cmd} -o "${join(config.output, filename)}" ${extraArgs} ./cmd`; - - const result = run(buildCmd, config.path, { - GOOS: p.goos, - GOARCH: p.goarch, - CGO_ENABLED: "1", - }); - - if (!result) { - logger.warning(`Failed to build ${filename}`); - success = false; - break; - } - } - } - return success; }; -const buildRust = (name, config, requestedPlatforms, extraArgs = "") => { - logger.info(`Building ${name} (Rust)...`); - mkdirSync(config.output, { recursive: true }); - - for (const cmd of config.commands) { - // Only append extra args to build commands - const finalCmd = cmd.includes("build") ? `${cmd} ${extraArgs}` : cmd; - if (!run(finalCmd, config.path)) return false; - } - - const isWin = - process.platform === "win32" || - (requestedPlatforms && requestedPlatforms.includes("win")); - const ext = isWin ? ".exe" : ""; - - // Check for source binary - const sourceBin = join(config.path, config.binaryPath + ext); - const destName = `packet-sender${ext}`; - const destPath = join(config.output, destName); +// Parse command line arguments +const args = process.argv.slice(2); - logger.step(`Copying binary to ${destPath}...`); +// Show help message if requested +if (args.includes("--help") || args.includes("-h")) { + logger.info(` +${colors.bright}${colors.cyan}Hyperloop Control Station Build Script${colors.reset} + +${colors.bright}Usage:${colors.reset} + node build.mjs [options] + +${colors.bright}Options:${colors.reset} + ${colors.green}--platform${colors.reset} P Specify backend platform (windows, linux, mac, all) + ${colors.green}--backend${colors.reset} Build only backends + ${colors.green}--frontend${colors.reset} Build only frontends + ${colors.green}--common-front${colors.reset} Build common-front library + ${colors.green}--control-station${colors.reset} Build control-station frontend + ${colors.green}--ethernet-view${colors.reset} Build ethernet-view frontend + ${colors.green}--help, -h${colors.reset} Show this help message + +${colors.bright}Examples:${colors.reset} + node build.mjs ${colors.dim}# Build everything (all platforms)${colors.reset} + node build.mjs --platform windows ${colors.dim}# Build everything for Windows${colors.reset} + node build.mjs --backend ${colors.dim}# Build only backends (all platforms)${colors.reset} + node build.mjs --ethernet-view ${colors.dim}# Build only ethernet-view${colors.reset} + ${colors.brightYellow}For npm shortcuts, see package.json scripts section${colors.reset} +`); + process.exit(0); +} - if (existsSync(sourceBin)) { - copyFileSync(sourceBin, destPath); - return true; - } else { - logger.error(`Rust binary not found at ${sourceBin}`); - return false; - } +/** + * Gets the value of a command line argument flag. + * @param {string} flag - The flag to look for (e.g., "--platform"). + * @returns {string | null} The value after the flag, or null if not found. + * @example + * const platform = getArgValue("--platform"); + * // Returns "windows" if command was: node build.mjs --platform windows + */ +const getArgValue = (flag) => { + // Find index of the flag + const idx = args.findIndex((a) => a === flag); + // Return next argument if flag exists and has a value + return idx !== -1 && args[idx + 1] ? args[idx + 1] : null; }; -const buildFrontend = (name, config, extraArgs = "") => { - if (config.optional && !existsSync(join(config.path, "package.json"))) { - logger.warning(`Skipping ${name} (not initialized)`); - return true; - } - - logger.info(`Building ${name}...`); - - for (const cmd of config.commands) { - const finalCmd = cmd.includes("build") ? `${cmd} ${extraArgs}` : cmd; - if (!run(finalCmd, config.path)) return false; - } - - logger.step(`Copying to renderer/${name}...`); - if (existsSync(config.dest)) - rmSync(config.dest, { recursive: true, force: true }); - - const distPath = join(config.path, "dist"); - if (existsSync(distPath)) { - cpSync(distPath, config.dest, { recursive: true }); - return true; +// Get platform argument value +const platformArg = getArgValue("--platform"); + +// Check if only --platform is specified (treat as build all for that platform) +const onlyPlatformSpecified = + platformArg && args.length === 2 && args[0] === "--platform"; + +// Determine what to build based on arguments +// Build everything if no args or only platform specified +const buildAll = args.length === 0 || onlyPlatformSpecified; +// Build only backend if --backend flag is present +const backendOnly = args.includes("--backend"); +// Build only frontend if --frontend flag is present +const frontendOnly = args.includes("--frontend"); +// Build common-front if explicitly requested, or if building frontend/all +const buildCommonFront = + args.includes("--common-front") || frontendOnly || (!backendOnly && buildAll); +// Build control-station if explicitly requested, or if building frontend/all +const buildControlStation = + args.includes("--control-station") || + frontendOnly || + (!backendOnly && buildAll); +// Build ethernet-view if explicitly requested, or if building frontend/all +const buildEthernetView = + args.includes("--ethernet-view") || + frontendOnly || + (!backendOnly && buildAll); +// Build backend if explicitly requested, or if building all, or if nothing else is being built +const buildBackend = + backendOnly || + (!frontendOnly && buildAll) || + (!buildCommonFront && !buildControlStation && !buildEthernetView); + +logger.header("🚀 Building Hyperloop Control Station"); + +// Setup: create necessary directories +mkdirSync(join(__dirname, "binaries"), { recursive: true }); +mkdirSync(join(__dirname, "renderer"), { recursive: true }); + +// Backend build section +if (buildBackend) { + logger.info("📦 Building backend..."); + // Path to backend source directory + const backendDir = join(root, "backend/cmd"); + // Path to binaries output directory + const binDir = join(__dirname, "binaries"); + + // Define all supported platforms for cross-compilation + const allPlatforms = [ + { + goos: "windows", + goarch: "amd64", + out: "backend-windows-amd64.exe", + name: "windows-amd64", + alias: "windows", + }, + { + goos: "linux", + goarch: "amd64", + out: "backend-linux-amd64", + name: "linux-amd64", + alias: "linux", + }, + { + goos: "darwin", + goarch: "amd64", + out: "backend-darwin-amd64", + name: "darwin-amd64", + alias: "mac", + }, + { + goos: "darwin", + goarch: "arm64", + out: "backend-darwin-arm64", + name: "darwin-arm64", + alias: "mac", + }, + ]; + + let platforms; + + // Filter platforms based on --platform argument + if (platformArg) { + if (platformArg === "all") { + // Build all platforms + platforms = allPlatforms; + } else { + // Filter to specific platform alias + platforms = allPlatforms.filter((p) => p.alias === platformArg); + // Error if platform not found + if (platforms.length === 0) { + logger.error(`Unknown platform: ${platformArg}`); + logger.error(`Available: windows, linux, mac, all`); + process.exit(1); + } + } } else { - logger.error(`Build output not found at ${distPath}`); - return false; + // Build all platforms if no platform specified + platforms = allPlatforms; } -}; - -// --- Argument Parsing --- - -const args = process.argv.slice(2); -const doubleDashIndex = args.indexOf("--"); -const scriptArgs = - doubleDashIndex !== -1 ? args.slice(0, doubleDashIndex) : args; -const extraArgs = - doubleDashIndex !== -1 ? args.slice(doubleDashIndex + 1).join(" ") : ""; -const requestedPlatforms = []; -if (scriptArgs.includes("--win") || scriptArgs.includes("--windows")) - requestedPlatforms.push("win"); -if (scriptArgs.includes("--linux")) requestedPlatforms.push("linux"); -if (scriptArgs.includes("--mac") || scriptArgs.includes("--macos")) - requestedPlatforms.push("mac"); -if (scriptArgs.includes("--all")) requestedPlatforms.push("all"); - -// Handle Overrides: --target.prop=value -scriptArgs.forEach((arg) => { - if (arg.startsWith("--") && arg.includes(".") && arg.includes("=")) { - const [key, value] = arg.slice(2).split("="); - const [target, prop] = key.split("."); - if (CONFIG[target] && prop) { - const finalValue = prop === "commands" ? value.split(",") : value; - CONFIG[target][prop] = finalValue; - logger.info(`Override: ${target}.${prop} = ${finalValue}`); - } + // Build backend for each selected platform + for (const p of platforms) { + logger.step(`Building ${colors.magenta}${p.name}${colors.reset}...`); + // Run go build with platform-specific environment variables + run(`go build -o "${join(binDir, p.out)}" .`, backendDir, { + GOOS: p.goos, + GOARCH: p.goarch, + CGO_ENABLED: "1", + }); } -}); - -const specificTargets = Object.keys(CONFIG).filter((key) => - scriptArgs.includes(`--${key}`) +} + +// Frontend - Common library build +if (buildCommonFront) { + console.log(); + logger.info("📦 Building common-front..."); + logger.step(`Building ${colors.magenta}common-front${colors.reset}...`); + // Install dependencies + run("npm ci", join(root, "common-front")); + // Build the library + run("npm run build", join(root, "common-front")); +} + +// Frontend - Control Station build +if (buildControlStation) { + console.log(); + logger.info("📦 Building control-station..."); + logger.step(`Building ${colors.magenta}control-station${colors.reset}...`); + // Install dependencies + run("npm ci", join(root, "control-station")); + // Build the application + run("npm run build", join(root, "control-station")); + + logger.step("Copying static files..."); + // Remove existing renderer directory + rmSync(join(__dirname, "renderer/control-station"), { + recursive: true, + force: true, + }); + // Copy built static files to renderer directory + cpSync( + join(root, "control-station/static"), + join(__dirname, "renderer/control-station"), + { recursive: true } + ); +} + +// Frontend - Ethernet View build +if (buildEthernetView) { + console.log(); + logger.info("📦 Building ethernet-view..."); + logger.step(`Building ${colors.magenta}ethernet-view${colors.reset}...`); + // Install dependencies + run("npm ci", join(root, "ethernet-view")); + // Build the application + run("npm run build", join(root, "ethernet-view")); + + logger.step("Copying static files..."); + // Remove existing renderer directory + rmSync(join(__dirname, "renderer/ethernet-view"), { + recursive: true, + force: true, + }); + // Copy built static files to renderer directory + cpSync( + join(root, "ethernet-view/static"), + join(__dirname, "renderer/ethernet-view"), + { recursive: true } + ); +} + +// Install Electron dependencies if any frontend was built +if (buildControlStation || buildEthernetView) { + console.log(); + logger.info("📦 Installing Electron dependencies..."); + // Install Electron app dependencies + run("npm ci", __dirname); +} + +console.log(); +logger.success("✅ Build complete!"); +console.log(); +// Show helpful next steps +console.log( + `${colors.dim}To run in development: ${colors.reset}${colors.cyan}npm start${colors.reset}` ); -const targetsToBuild = - specificTargets.length > 0 ? specificTargets : Object.keys(CONFIG); - -// --- Main Execution --- - -logger.header("Hyperloop Control Station Build"); - -(async () => { - let frontendBuilt = false; - let allSuccess = true; - - for (const key of targetsToBuild) { - const config = CONFIG[key]; - let success = true; - - if (config.type === "go") { - success = buildBackend(config, requestedPlatforms, extraArgs); - } else if (config.type === "rust") { - success = buildRust(key, config, requestedPlatforms, extraArgs); - } else if (config.type === "frontend") { - success = buildFrontend(key, config, extraArgs); - if (success && !config.optional) frontendBuilt = true; - } - - if (!success) { - allSuccess = false; - if (process.env.CI) process.exit(1); - } - } - - if (frontendBuilt && !process.env.CI) { - logger.info("Finalizing Electron..."); - run("pnpm --filter electron-app install --frozen-lockfile", __dirname); - } - - if (allSuccess) { - logger.success("Build complete!"); - } else { - logger.error("Build failed."); - process.exit(1); - } -})(); +console.log( + `${colors.dim}To build installers: ${colors.reset}${colors.cyan}npm run dist${colors.reset}` +); +console.log(); diff --git a/electron-app/dev-app-update.yml b/electron-app/dev-app-update.yml deleted file mode 100644 index 27ef92e02..000000000 --- a/electron-app/dev-app-update.yml +++ /dev/null @@ -1,4 +0,0 @@ -provider: github -owner: Hyperloop-UPV -repo: software -updaterCacheDirName: hyperloop-control-station/updater diff --git a/electron-app/icon.png b/electron-app/icon.png deleted file mode 100644 index 3555d0ad7..000000000 Binary files a/electron-app/icon.png and /dev/null differ diff --git a/electron-app/icons/1024x1024.png b/electron-app/icons/1024x1024.png deleted file mode 100644 index 1b2a9c44f..000000000 Binary files a/electron-app/icons/1024x1024.png and /dev/null differ diff --git a/electron-app/icons/128x128.png b/electron-app/icons/128x128.png deleted file mode 100644 index a498ea646..000000000 Binary files a/electron-app/icons/128x128.png and /dev/null differ diff --git a/electron-app/icons/16x16.png b/electron-app/icons/16x16.png deleted file mode 100644 index ccd0fdfb2..000000000 Binary files a/electron-app/icons/16x16.png and /dev/null differ diff --git a/electron-app/icons/24x24.png b/electron-app/icons/24x24.png deleted file mode 100644 index b4c0a5c1f..000000000 Binary files a/electron-app/icons/24x24.png and /dev/null differ diff --git a/electron-app/icons/256x256.png b/electron-app/icons/256x256.png deleted file mode 100644 index 3d83ffa18..000000000 Binary files a/electron-app/icons/256x256.png and /dev/null differ diff --git a/electron-app/icons/32x32.png b/electron-app/icons/32x32.png deleted file mode 100644 index 58c8034d9..000000000 Binary files a/electron-app/icons/32x32.png and /dev/null differ diff --git a/electron-app/icons/48x48.png b/electron-app/icons/48x48.png deleted file mode 100644 index 0aecdf06e..000000000 Binary files a/electron-app/icons/48x48.png and /dev/null differ diff --git a/electron-app/icons/512x512.png b/electron-app/icons/512x512.png deleted file mode 100644 index 4c1778dec..000000000 Binary files a/electron-app/icons/512x512.png and /dev/null differ diff --git a/electron-app/icons/64x64.png b/electron-app/icons/64x64.png deleted file mode 100644 index 37afc188c..000000000 Binary files a/electron-app/icons/64x64.png and /dev/null differ diff --git a/electron-app/icons/icon.icns b/electron-app/icons/icon.icns deleted file mode 100644 index 85fc7cac1..000000000 Binary files a/electron-app/icons/icon.icns and /dev/null differ diff --git a/electron-app/icons/icon.ico b/electron-app/icons/icon.ico deleted file mode 100644 index a0d5ea739..000000000 Binary files a/electron-app/icons/icon.ico and /dev/null differ diff --git a/electron-app/main.js b/electron-app/main.js index ef5841b58..7c5138023 100644 --- a/electron-app/main.js +++ b/electron-app/main.js @@ -4,73 +4,30 @@ * Handles application lifecycle, initialization, and cleanup of processes and windows. */ -import { app, BrowserWindow, dialog } from "electron"; -import pkg from "electron-updater"; -import { getConfigManager } from "./src/config/configInstance.js"; -import { setupIpcHandlers } from "./src/ipc/handlers.js"; +import { app, dialog, BrowserWindow } from "electron"; +import { createWindow } from "./src/windows/mainWindow.js"; import { startBackend, stopBackend } from "./src/processes/backend.js"; +import { setupIpcHandlers } from "./src/ipc/handlers.js"; +import { getConfigManager } from "./src/config/configInstance.js"; import { stopPacketSender } from "./src/processes/packetSender.js"; import { logger } from "./src/utils/logger.js"; -import { createWindow } from "./src/windows/mainWindow.js"; - -const { autoUpdater } = pkg; // Setup IPC handlers for renderer process communication setupIpcHandlers(); -app.setName("hyperloop-control-station"); - // App lifecycle: wait for Electron to be ready app.whenReady().then(async () => { // Initialize ConfigManager and ensure config exists BEFORE starting backend - logger.electron.header("Initializing configuration..."); + logger.electron.info("Initializing configuration..."); // Get ConfigManager instance (creates config from template if needed) await getConfigManager(); - logger.electron.header("Configuration ready"); + logger.electron.info("Configuration ready"); // Start backend process - try { - await startBackend(); - logger.electron.header("Backend process spawned"); - } catch (error) { - // Start backend already shows these errors - return; - } + startBackend(); // Create main application window createWindow(); - logger.electron.header("Main application window created"); - - // Updater setup - if (!app.isPackaged) { - autoUpdater.forceDevUpdateConfig = true; - } - - autoUpdater.logger = { - info: (message) => logger.electron.info(message), - error: (message) => logger.electron.error(message), - warn: (message) => logger.electron.warning(message), - debug: (message) => logger.electron.debug(message), - }; - - // Check for updates - autoUpdater.checkForUpdates(); - - // Handle update downloaded event - autoUpdater.on("update-downloaded", (info) => { - dialog - .showMessageBox({ - type: "info", - title: "Update Ready", - message: `Version ${info.version} has been downloaded. Restart now to install?`, - buttons: ["Restart", "Later"], - }) - .then((result) => { - if (result.response === 0) { - autoUpdater.quitAndInstall(); - } - }); - }); // Handle macOS app activation (reopen window when dock icon clicked) app.on("activate", () => { diff --git a/electron-app/package.json b/electron-app/package.json index a37d20f36..997531854 100644 --- a/electron-app/package.json +++ b/electron-app/package.json @@ -1,5 +1,5 @@ { - "name": "electron-app", + "name": "hyperloop-control-station", "version": "1.0.0", "description": "Hyperloop UPV Control Station", "main": "main.js", @@ -11,7 +11,7 @@ "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/Hyperloop-UPV/software.git" + "url": "https://github.com/HyperloopUPV-H8/software.git" }, "scripts": { "start": "electron .", @@ -21,41 +21,37 @@ "dist:mac": "electron-builder --mac", "dist:linux": "electron-builder --linux", "test": "vitest run", - "build-icons": "electron-icon-builder --input=./icon.png --output=./ --flatten", "build": "node build.mjs", - "build:win": "node build.mjs --win", - "build:linux": "node build.mjs --linux", - "build:mac": "node build.mjs --mac", + "build:win": "node build.mjs --platform windows", + "build:linux": "node build.mjs --platform linux", + "build:mac": "node build.mjs --platform mac", + "build:all": "node build.mjs --platform all", "build:backend": "node build.mjs --backend", - "build:backend:win": "node build.mjs --backend --win", - "build:backend:linux": "node build.mjs --backend --linux", - "build:backend:mac": "node build.mjs --backend --mac", - "build:testing": "node build.mjs --testing-view", - "build:competition": "node build.mjs --competition-view", + "build:backend:win": "node build.mjs --backend --platform windows", + "build:backend:linux": "node build.mjs --backend --platform linux", + "build:backend:mac": "node build.mjs --backend --platform mac", + "build:frontend": "node build.mjs --frontend", + "build:common": "node build.mjs --common-front", + "build:control-station": "node build.mjs --control-station", + "build:ethernet-view": "node build.mjs --ethernet-view", "asar:win": "asar list dist/win-unpacked/resources/app.asar | findstr /V node_modules", "asar:mac": "asar list dist/mac-unpacked/resources/app.asar | findstr /V node_modules", "asar:linux": "asar list dist/linux-unpacked/resources/app.asar | findstr /V node_modules" }, "dependencies": { "@iarna/toml": "^2.2.5", - "electron-store": "^11.0.2", - "electron-updater": "^6.7.3", - "picocolors": "^1.1.1" + "electron-store": "^8.1.0" }, "devDependencies": { - "@vitest/coverage-v8": "^4.0.18", + "@vitest/coverage-v8": "^4.0.14", "asar": "^3.2.0", - "electron": "^40.1.0", - "electron-builder": "^26.7.0", - "vitest": "^4.0.18" + "electron": "^28.0.0", + "electron-builder": "^24.9.1", + "vitest": "^4.0.14" }, "build": { "appId": "com.hyperloop.controlstation", - "publish": { - "provider": "github", - "owner": "Hyperloop-UPV", - "repo": "software" - }, + "publish": null, "productName": "Hyperloop-Control-Station", "directories": { "output": "dist" @@ -86,20 +82,14 @@ "nsis", "portable" ], - "icon": "icons/icon.ico" - }, - "nsis": { - "artifactName": "${productName}-Setup-${version}.${ext}" - }, - "portable": { - "artifactName": "${productName}-Portable-${version}.${ext}" + "icon": "icon.ico" }, "mac": { "target": [ "dmg", "zip" ], - "icon": "icons/icon.icns", + "icon": "icon.icns", "category": "public.app-category.utilities", "artifactName": "${productName}-${version}-macos-${arch}.${ext}" }, @@ -108,7 +98,7 @@ "AppImage", "deb" ], - "icon": "icons/512x512.png", + "icon": "icon.png", "category": "Utility", "artifactName": "${productName}-${version}-linux-${arch}.${ext}" } diff --git a/electron-app/src/config/configInstance.js b/electron-app/src/config/configInstance.js index ae8068252..842327343 100644 --- a/electron-app/src/config/configInstance.js +++ b/electron-app/src/config/configInstance.js @@ -4,8 +4,8 @@ * Provides async wrappers for ConfigManager operations with lazy initialization. */ +import { getUserConfigPath, getTemplatePath } from "../utils/paths.js"; import { logger } from "../utils/logger.js"; -import { getTemplatePath, getUserConfigPath } from "../utils/paths.js"; // Store the singleton ConfigManager instance let configManager = null; @@ -30,8 +30,8 @@ async function getConfigManager() { // Create new ConfigManager instance configManager = new ConfigManager(userConfigPath, templatePath); logger.config.info("ConfigManager initialized"); - logger.config.path("User config", userConfigPath); - logger.config.path("Template path", templatePath); + logger.config.info("User config:", userConfigPath); + logger.config.info("Template:", templatePath); } // Return the singleton instance @@ -132,4 +132,4 @@ async function importConfig() { } } -export { getConfigManager, importConfig, readConfig, writeConfig }; +export { getConfigManager, readConfig, writeConfig, importConfig }; diff --git a/electron-app/src/ipc/handlers.js b/electron-app/src/ipc/handlers.js index 2aefe2bf4..414f1e410 100644 --- a/electron-app/src/ipc/handlers.js +++ b/electron-app/src/ipc/handlers.js @@ -7,19 +7,19 @@ * - Folder selection dialogs */ -import { dialog, ipcMain } from "electron"; +import { ipcMain, dialog } from "electron"; import { - importConfig, readConfig, writeConfig, + importConfig, } from "../config/configInstance.js"; -import { restartBackend } from "../processes/backend.js"; -import { logger } from "../utils/logger.js"; import { + loadView, getCurrentView, getMainWindow, - loadView, } from "../windows/mainWindow.js"; +import { restartBackend } from "../processes/backend.js"; +import { logger } from "../utils/logger.js"; /** * Initializes all IPC handlers for communication between main and renderer processes. diff --git a/electron-app/src/menu/menu.js b/electron-app/src/menu/menu.js index c0436ab79..6cd06f19d 100644 --- a/electron-app/src/menu/menu.js +++ b/electron-app/src/menu/menu.js @@ -4,14 +4,13 @@ * Defines menu structure with File, View, Tools, and Help sections with keyboard shortcuts and actions. */ -import { Menu, app, dialog } from "electron"; -import fs from "fs"; +import { Menu, dialog, app } from "electron"; +import { getBinaryPath } from "../utils/paths.js"; import { - getPacketSenderProcess, startPacketSender, - stopPacketSender, + getPacketSenderProcess, } from "../processes/packetSender.js"; -import { getBinaryPath } from "../utils/paths.js"; +import fs from "fs"; import { loadView } from "../windows/mainWindow.js"; /** @@ -44,17 +43,19 @@ function createMenu(mainWindow) { label: "View", submenu: [ { - label: "Competition View", + label: "Control Station", accelerator: "CmdOrCtrl+1", click: () => { - loadView("competition-view"); + loadView("control-station"); + loadView("control-station"); }, }, { - label: "Testing View", + label: "Ethernet View", accelerator: "CmdOrCtrl+2", click: () => { - loadView("testing-view"); + loadView("ethernet-view"); + loadView("ethernet-view"); }, }, { type: "separator" }, @@ -83,7 +84,7 @@ function createMenu(mainWindow) { } const packetSenderProcess = getPacketSenderProcess(); if (!packetSenderProcess || packetSenderProcess.killed) { - startPacketSender(["random"]); + startPacketSender(["--help"]); } }, }, @@ -109,7 +110,8 @@ function createMenu(mainWindow) { type: "info", title: "About", message: "Hyperloop UPV Control Station", - detail: `Version ${app.getVersion()}\n\nControl and monitoring software for Hyperloop pod.`, + detail: + "Version 1.0.0\n\nControl and monitoring software for Hyperloop pod.", }); }, }, diff --git a/electron-app/src/processes/backend.js b/electron-app/src/processes/backend.js index 1e215619d..ccdc7b1ba 100644 --- a/electron-app/src/processes/backend.js +++ b/electron-app/src/processes/backend.js @@ -5,15 +5,16 @@ */ import { spawn } from "child_process"; -import { app, dialog } from "electron"; -import fs from "fs"; -import path from "path"; -import { logger } from "../utils/logger.js"; +import { dialog } from "electron"; import { getAppPath, getBinaryPath, getUserConfigPath, } from "../utils/paths.js"; +import fs from "fs"; +import { app } from "electron"; +import path from "path"; +import { logger } from "../utils/logger.js"; // Get the application root path const appPath = getAppPath(); @@ -31,82 +32,69 @@ let lastBackendError = null; * startBackend(); */ function startBackend() { - return new Promise((resolve, reject) => { - // Get paths for binary and config - const backendBin = getBinaryPath("backend"); - const configPath = getUserConfigPath(); - - // Check if binary exists before attempting to start - if (!fs.existsSync(backendBin)) { - logger.backend.error(`Backend binary not found: ${backendBin}`); - dialog.showErrorBox( - "Error", - `Backend binary not found at: ${backendBin}` - ); - return reject(new Error(`Backend binary not found: ${backendBin}`)); - } + // Get paths for binary and config + const backendBin = getBinaryPath("backend"); + const configPath = getUserConfigPath(); + + // Check if binary exists before attempting to start + if (!fs.existsSync(backendBin)) { + logger.backend.error(`Backend binary not found: ${backendBin}`); + dialog.showErrorBox("Error", `Backend binary not found at: ${backendBin}`); + return; + } + + logger.backend.info(`Starting backend: ${backendBin}, config: ${configPath}`); + + // Set working directory to backend/cmd in development, or resources in production + const workingDir = !app.isPackaged + ? path.join(appPath, "..", "backend", "cmd") + : path.dirname(configPath); - logger.backend.info( - `Starting backend: ${backendBin}, config: ${configPath}` + // Spawn the backend process with config argument + backendProcess = spawn(backendBin, ["--config", configPath], { + cwd: workingDir, + }); + + // Log stdout output from backend + backendProcess.stdout.on("data", (data) => { + logger.backend.info(`${data.toString().trim()}`); + }); + + // Capture stderr output (where Go errors/panics are written) + backendProcess.stderr.on("data", (data) => { + const errorMsg = data.toString().trim(); + logger.backend.error(errorMsg); + // Store the last error message + lastBackendError = errorMsg; + }); + + // Handle spawn errors + backendProcess.on("error", (error) => { + logger.backend.error(`Failed to start backend: ${error.message}`); + dialog.showErrorBox( + "Backend Error", + `Failed to start backend: ${error.message}` ); + }); - // Set working directory to backend/cmd in development, or resources in production - const workingDir = !app.isPackaged - ? path.join(appPath, "..", "backend", "cmd") - : path.dirname(configPath); - - // Spawn the backend process with config argument - backendProcess = spawn(backendBin, ["--config", configPath], { - cwd: workingDir, - }); - - // Log stdout output from backend - backendProcess.stdout.on("data", (data) => { - logger.backend.info(`${data.toString().trim()}`); - }); - - // Capture stderr output (where Go errors/panics are written) - backendProcess.stderr.on("data", (data) => { - const errorMsg = data.toString().trim(); - logger.backend.error(errorMsg); - // Store the last error message - lastBackendError = errorMsg; - }); - - // Handle spawn errors - backendProcess.on("error", (error) => { - logger.backend.error(`Failed to start backend: ${error.message}`); - dialog.showErrorBox( - "Backend Error", - `Failed to start backend: ${error.message}` - ); - return reject(new Error(`Failed to start backend: ${error.message}`)); - }); - - // If the backend didn't fail in this period of time, resolve the promise - setTimeout(() => { - resolve(backendProcess); - }, 1000); - - // Handle process exit - backendProcess.on("close", (code) => { - logger.backend.info(`Backend process exited with code ${code}`); - // Show error dialog if process crashed (non-zero exit code) - if (code !== 0 && code !== null) { - // Build error message with actual error details - let errorMessage = `Backend exited with code ${code}`; - - if (lastBackendError) { - errorMessage += `\n\n${lastBackendError}`; - } else { - errorMessage += "\n\n(No error output captured)"; - } - - dialog.showErrorBox("Backend Crashed", errorMessage); - // Clear error message after showing - lastBackendError = null; + // Handle process exit + backendProcess.on("close", (code) => { + logger.backend.info(`Backend process exited with code ${code}`); + // Show error dialog if process crashed (non-zero exit code) + if (code !== 0 && code !== null) { + // Build error message with actual error details + let errorMessage = `Backend exited with code ${code}`; + + if (lastBackendError) { + errorMessage += `\n\n${lastBackendError}`; + } else { + errorMessage += "\n\n(No error output captured)"; } - }); + + dialog.showErrorBox("Backend Crashed", errorMessage); + // Clear error message after showing + lastBackendError = null; + } }); } @@ -140,4 +128,4 @@ function restartBackend() { startBackend(); } -export { restartBackend, startBackend, stopBackend }; +export { startBackend, stopBackend, restartBackend }; diff --git a/electron-app/src/processes/packetSender.js b/electron-app/src/processes/packetSender.js index e6efc5cf9..67a506cfd 100644 --- a/electron-app/src/processes/packetSender.js +++ b/electron-app/src/processes/packetSender.js @@ -5,9 +5,9 @@ */ import { spawn } from "child_process"; +import { getBinaryPath } from "../utils/paths.js"; import fs from "fs"; import { logger } from "../utils/logger.js"; -import { getBinaryPath } from "../utils/paths.js"; // Store the packet sender process instance let packetSenderProcess = null; @@ -90,7 +90,7 @@ function restartPacketSender() { // Wait before starting new process to ensure cleanup setTimeout(() => { // Start with help arguments - startPacketSender(["random"]); + startPacketSender(["--help"]); }, 500); } } @@ -110,8 +110,8 @@ function getPacketSenderProcess() { } export { - getPacketSenderProcess, - restartPacketSender, startPacketSender, stopPacketSender, + restartPacketSender, + getPacketSenderProcess, }; diff --git a/electron-app/src/utils/colors.js b/electron-app/src/utils/colors.js new file mode 100644 index 000000000..fd982d517 --- /dev/null +++ b/electron-app/src/utils/colors.js @@ -0,0 +1,52 @@ +/** + * @module utils + * @description ANSI color codes for terminal output formatting. + * Provides color constants for styling console messages with reset, brightness, and color options. + */ + +/** + * Object containing ANSI escape codes for terminal text coloring and formatting. + * @type {Object} + * @property {string} reset - ANSI reset code to clear all formatting. + * @property {string} bright - ANSI code for bright/bold text. + * @property {string} dim - ANSI code for dim text. + * @property {string} red - ANSI code for red text. + * @property {string} green - ANSI code for green text. + * @property {string} yellow - ANSI code for yellow text. + * @property {string} blue - ANSI code for blue text. + * @property {string} magenta - ANSI code for magenta text. + * @property {string} cyan - ANSI code for cyan text. + * @property {string} white - ANSI code for white text. + * @property {string} gray - ANSI code for gray text. + * @property {string} brightRed - ANSI code for bright red text. + * @property {string} brightGreen - ANSI code for bright green text. + * @property {string} brightYellow - ANSI code for bright yellow text. + * @property {string} brightBlue - ANSI code for bright blue text. + * @property {string} brightMagenta - ANSI code for bright magenta text. + * @property {string} brightCyan - ANSI code for bright cyan text. + * @example + * import { colors } from './colors.js'; + * console.log(`${colors.green}Success!${colors.reset}`); + * console.log(`${colors.brightRed}Error!${colors.reset}`); + */ +export const colors = { + reset: "\x1b[0m", + bright: "\x1b[1m", + dim: "\x1b[2m", + + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + magenta: "\x1b[35m", + cyan: "\x1b[36m", + white: "\x1b[37m", + gray: "\x1b[90m", + + brightRed: "\x1b[91m", + brightGreen: "\x1b[92m", + brightYellow: "\x1b[93m", + brightBlue: "\x1b[94m", + brightMagenta: "\x1b[95m", + brightCyan: "\x1b[96m", +}; diff --git a/electron-app/src/utils/logger.js b/electron-app/src/utils/logger.js index 4fab38227..e837402b8 100644 --- a/electron-app/src/utils/logger.js +++ b/electron-app/src/utils/logger.js @@ -5,7 +5,7 @@ * All logging methods write directly to the console with ANSI color formatting. */ -import pc from "picocolors"; +import { colors } from "./colors.js"; /** * @typedef {Object} LoggerMethods @@ -21,51 +21,118 @@ import pc from "picocolors"; /** * Creates a set of logger methods with an optional prefix and color. - * @param {string} [prefix=""] - Optional prefix text. - * @param {string} [prefixColor=""] - Color name from picocolors (e.g., "cyan", "magenta"). + * @param {string} [prefix=""] - Optional prefix text to display before log messages. + * @param {keyof typeof colors} [prefixColor=""] - Optional color name from the colors object for the prefix. + * @returns {LoggerMethods} Logger methods object. + * @example + * const myLogger = createLoggerMethods("MyApp", "cyan"); + * myLogger.info("Application started"); + * myLogger.error("Something went wrong"); */ function createLoggerMethods(prefix = "", prefixColor = "") { - // Get the color function from picocolors, or fallback to a plain string - const colorFn = pc[prefixColor] || ((s) => s); - const prefixText = prefix ? `${colorFn(`[${prefix}]`)} ` : ""; + const prefixText = prefix + ? `${colors[prefixColor] || ""}[${prefix}]${colors.reset} ` + : ""; return { + /** + * Logs an info-level message to the console. + * @param {string} msg - The message to log. + * @param {...any} args - Additional arguments for console.log. + */ info: (msg, ...args) => { - console.log(`${prefixText}${pc.blue("[INFO]")} ${msg}`, ...args); + console.log( + `${prefixText}${colors.blue}[INFO]${colors.reset} ${msg}`, + ...args + ); }, + /** + * Logs a success-level message to the console. + * @param {string} msg - The message to log. + * @param {...any} args - Additional arguments for console.log. + */ success: (msg, ...args) => { - console.log(`${prefixText}${pc.green("[OK]")} ${msg}`, ...args); + console.log( + `${prefixText}${colors.brightGreen}[OK]${colors.reset} ${msg}`, + ...args + ); }, + /** + * Logs a warning-level message to the console. + * @param {string} msg - The message to log. + * @param {...any} args - Additional arguments for console.log. + */ warning: (msg, ...args) => { - console.log(`${prefixText}${pc.yellow("[WARN]")} ${msg}`, ...args); + console.log( + `${prefixText}${colors.yellow}[WARN]${colors.reset} ${msg}`, + ...args + ); }, + /** + * Logs an error-level message to the console. + * @param {string} msg - The message to log. + * @param {...any} args - Additional arguments for console.error. + */ error: (msg, ...args) => { - console.error(`${prefixText}${pc.red("[ERROR]")} ${msg}`, ...args); + console.error( + `${prefixText}${colors.brightRed}[ERROR]${colors.reset} ${msg}`, + ...args + ); }, + /** + * Logs a debug-level message to the console. + * @param {string} msg - The message to log. + * @param {...any} args - Additional arguments for console.log. + */ debug: (msg, ...args) => { - console.log(`${prefixText}${pc.gray("[DEBUG]")} ${msg}`, ...args); + console.log( + `${prefixText}${colors.gray}[DEBUG]${colors.reset} ${msg}`, + ...args + ); }, + /** + * Prints a header message with bright cyan formatting. + * @param {string} msg - The header message. + */ header: (msg) => { - console.log(`\n${prefixText}${pc.bold(pc.cyan(msg))}\n`); + console.log( + `\n${prefixText}${colors.bright}${colors.cyan}${msg}${colors.reset}\n` + ); }, + /** + * Prints a step message with dim formatting. + * @param {string} msg - The step message. + * @param {...any} args - Additional arguments for console.log. + */ step: (msg, ...args) => { - console.log(`${prefixText}${pc.dim(` > ${msg}`)}`, ...args); + console.log( + `${prefixText}${colors.dim} > ${msg}${colors.reset}`, + ...args + ); }, + /** + * Prints a labeled path with dim label and cyan path. + * @param {string} label - Label for the path (e.g., "Config"). + * @param {string} path - The path value. + */ path: (label, path) => { - console.log(`${prefixText} ${pc.dim(`${label}:`)} ${pc.cyan(path)}`); + console.log( + `${prefixText} ${colors.dim}${label}:${colors.reset} ${colors.cyan}${path}${colors.reset}` + ); }, }; } /** * @typedef {LoggerMethods & { + * colors: typeof colors, * electron: LoggerMethods, * backend: LoggerMethods, * config: LoggerMethods, @@ -87,6 +154,8 @@ function createLoggerMethods(prefix = "", prefixColor = "") { * logger.log("green", "Success message"); */ export const logger = { + colors, + // Default logger methods (no prefix) ...createLoggerMethods(), @@ -102,16 +171,16 @@ export const logger = { * @param {string} msg - Message to log. */ process: (name, msg) => { - console.log(`${pc.magenta}[${name}]${pc.reset} ${msg}`); + console.log(`${colors.magenta}[${name}]${colors.reset} ${msg}`); }, /** * Logs a message in a specified color. - * @param {keyof typeof pc} color - Color key from the pc object. + * @param {keyof typeof colors} color - Color key from the colors object. * @param {string} msg - Message to log. * @param {...any} args - Additional arguments for console.log. */ log: (color, msg, ...args) => { - console.log(`${pc[color] || ""}${msg}${pc.reset}`, ...args); + console.log(`${colors[color] || ""}${msg}${colors.reset}`, ...args); }, }; diff --git a/electron-app/src/utils/paths.js b/electron-app/src/utils/paths.js index f7bf70033..d3178d7e2 100644 --- a/electron-app/src/utils/paths.js +++ b/electron-app/src/utils/paths.js @@ -41,13 +41,6 @@ function getBinaryPath(name) { const arch = process.arch; const ext = platform === "win32" ? ".exe" : ""; - if (name === "packet-sender") { - if (!app.isPackaged) { - return path.join(getAppPath(), "binaries", `${name}${ext}`); - } - return path.join(process.resourcesPath, "binaries", `${name}${ext}`); - } - const goosMap = { win32: "windows", darwin: "darwin", @@ -115,4 +108,4 @@ function getTemplatePath() { return path.join(process.resourcesPath, "config.toml"); } -export { getAppPath, getBinaryPath, getTemplatePath, getUserConfigPath }; +export { getAppPath, getBinaryPath, getUserConfigPath, getTemplatePath }; diff --git a/electron-app/src/windows/mainWindow.js b/electron-app/src/windows/mainWindow.js index 259f8dc79..885d142b4 100644 --- a/electron-app/src/windows/mainWindow.js +++ b/electron-app/src/windows/mainWindow.js @@ -5,8 +5,8 @@ */ import { BrowserWindow, app, dialog } from "electron"; -import fs from "fs"; import path from "path"; +import fs from "fs"; import { createMenu } from "../menu/menu.js"; import { getAppPath } from "../utils/paths.js"; @@ -16,7 +16,7 @@ const appPath = getAppPath(); // Store the main window instance let mainWindow = null; // Track the currently loaded view -let currentView = "testing-view"; +let currentView = "ethernet-view"; /** * Creates and initializes the main application window. @@ -38,15 +38,13 @@ function createWindow() { contextIsolation: true, // Disable node integration for security nodeIntegration: false, - // Disable background throttling to prevent data loss when window is minimized - backgroundThrottling: false, }, title: "Hyperloop Control Station", backgroundColor: "#1a1a1a", }); // Load ethernet view by default - loadView(currentView); + loadView("ethernet-view"); // Create application menu createMenu(mainWindow); @@ -119,4 +117,4 @@ function getMainWindow() { return mainWindow; } -export { createWindow, getCurrentView, getMainWindow, loadView }; +export { createWindow, loadView, getCurrentView, getMainWindow }; diff --git a/ethernet-view/.gitignore b/ethernet-view/.gitignore new file mode 100644 index 000000000..533354d12 --- /dev/null +++ b/ethernet-view/.gitignore @@ -0,0 +1,30 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +.env + +static +*.zip +*tgz diff --git a/ethernet-view/index.html b/ethernet-view/index.html new file mode 100644 index 000000000..95b45b7e5 --- /dev/null +++ b/ethernet-view/index.html @@ -0,0 +1,31 @@ + + + + + + + + + Ethernet View + + +
+ + + diff --git a/ethernet-view/package-lock.json b/ethernet-view/package-lock.json new file mode 100644 index 000000000..e25d71ca1 --- /dev/null +++ b/ethernet-view/package-lock.json @@ -0,0 +1,5600 @@ +{ + "name": "frontend-h8", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend-h8", + "version": "0.0.0", + "dependencies": { + "@canvasjs/react-charts": "^1.0.0", + "@react-spring/web": "^9.7.2", + "@reduxjs/toolkit": "^1.9.0", + "axios": "^1.1.3", + "common": "file:../common-front", + "dotenv": "^16.0.3", + "events": "^3.3.0", + "lightweight-charts": "^4.1.2", + "lodash": "^4.17.21", + "nanoid": "^4.0.2", + "papaparse": "^5.4.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-icons": "^4.6.0", + "react-redux": "^8.0.5", + "react-router-dom": "^6.22.3", + "react-use-measure": "^2.1.1", + "react-virtualized-auto-sizer": "^1.0.7", + "react-window": "^1.8.8", + "vite-plugin-svgr": "^2.4.0", + "vite-tsconfig-paths": "^4.0.5", + "zustand": "^4.4.6", + "zustymiddleware": "^1.2.0", + "zustymiddlewarets": "^1.4.2" + }, + "devDependencies": { + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^13.4.0", + "@types/events": "^3.0.0", + "@types/lodash": "^4.14.191", + "@types/node": "^18.11.9", + "@types/papaparse": "^5.3.14", + "@types/react": "^18.0.24", + "@types/react-dom": "^18.0.8", + "@types/react-virtualized-auto-sizer": "^1.0.1", + "@types/react-window": "^1.8.5", + "@vitejs/plugin-react": "^2.2.0", + "jsdom": "^20.0.2", + "nock": "^13.2.9", + "sass": "^1.56.1", + "typescript": "^4.6.4", + "vite": "^3.2.10", + "vitest": "^0.25.2" + } + }, + "../common-front": { + "name": "@hyperloop-upv/common-front", + "version": "2.0.24", + "dependencies": { + "@react-spring/web": "^9.7.2", + "@reduxjs/toolkit": "^1.9.5", + "@rollup/plugin-typescript": "^11.1.0", + "@types/lodash": "^4.14.194", + "@types/node": "^18.16.1", + "@types/react": "^18.2.0", + "@vitejs/plugin-react": "^4.0.0", + "@zerollup/ts-transform-paths": "^1.7.18", + "hls.js": "^1.6.2", + "lodash": "^4.17.21", + "math": "^0.0.3", + "react": "^18.2.0", + "react-icons": "^4.9.0", + "react-redux": "^8.0.5", + "react-use-websocket": "^3.0.0", + "rollup-plugin-import-map": "^3.0.0", + "rollup-plugin-includepaths": "^0.2.4", + "rollup-plugin-postcss": "^4.0.2", + "rollup-plugin-typescript-paths": "^1.4.0", + "rollup-plugin-typescript2": "^0.34.1", + "sass": "^1.62.1", + "tslib": "^2.5.0", + "ttypescript": "^1.5.15", + "typescript": "^5.0.2", + "vite": "^4.5.14", + "vite-plugin-svgr": "^3.2.0", + "vite-tsconfig-paths": "^4.2.0", + "zustand": "^4.4.6" + } + }, + "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/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@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/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@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-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@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-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "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==", + "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==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.27.1.tgz", + "integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", + "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@canvasjs/charts": { + "version": "3.14.9", + "resolved": "https://registry.npmjs.org/@canvasjs/charts/-/charts-3.14.9.tgz", + "integrity": "sha512-pyjjfyPcmgsrvHJ+f6nh8FbiQx2tqHblVHggyH5Ldx6jZjm57j2WzWcwlDLjzlkrfGZ+8TSWS2ItBUhcAJh6Yw==", + "license": "SEE LICENSE IN ", + "peer": true + }, + "node_modules/@canvasjs/react-charts": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@canvasjs/react-charts/-/react-charts-1.0.2.tgz", + "integrity": "sha512-PZgJlDbGdMF4AN/KvrvGY9X50EByJMZ7MHfQB/U0aky9Onn9mt0CpsvwudBsBe+DofaV3SHR4SHn/Wfo/pubDw==", + "license": "MIT", + "peerDependencies": { + "@canvasjs/charts": "^3.7.5", + "react": ">=16.0.0" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.15.18.tgz", + "integrity": "sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.15.18.tgz", + "integrity": "sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", + "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", + "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", + "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types/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/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "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==", + "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==", + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@react-spring/animated": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.5.tgz", + "integrity": "sha512-Tqrwz7pIlsSDITzxoLS3n/v/YCUHQdOIKtOJf4yL6kYVSDTSmVK1LI1Q3M/uu2Sx4X3pIWF3xLUhlsA6SPNTNg==", + "license": "MIT", + "dependencies": { + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/core": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.7.5.tgz", + "integrity": "sha512-rmEqcxRcu7dWh7MnCcMXLvrf6/SDlSokLaLTxiPlAYi11nN3B5oiCUAblO72o+9z/87j2uzxa2Inm8UbLjXA+w==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~9.7.5", + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-spring/donate" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/rafz": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.7.5.tgz", + "integrity": "sha512-5ZenDQMC48wjUzPAm1EtwQ5Ot3bLIAwwqP2w2owG5KoNdNHpEJV263nGhCeKKmuA3vG2zLLOdu3or6kuDjA6Aw==", + "license": "MIT" + }, + "node_modules/@react-spring/shared": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.7.5.tgz", + "integrity": "sha512-wdtoJrhUeeyD/PP/zo+np2s1Z820Ohr/BbuVYv+3dVLW7WctoiN7std8rISoYoHpUXtbkpesSKuPIw/6U1w1Pw==", + "license": "MIT", + "dependencies": { + "@react-spring/rafz": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/types": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.7.5.tgz", + "integrity": "sha512-HVj7LrZ4ReHWBimBvu2SKND3cDVUPWKLqRTmWe/fNY6o1owGOX0cAHbdPDTMelgBlVbrTKrre6lFkhqGZErK/g==", + "license": "MIT" + }, + "node_modules/@react-spring/web": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-9.7.5.tgz", + "integrity": "sha512-lmvqGwpe+CSttsWNZVr+Dg62adtKhauGwLyGE/RRyZ8AAMLgb9x3NDMA5RMElXo+IMyTkPp7nxTB8ZQlmhb6JQ==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~9.7.5", + "@react-spring/core": "~9.7.5", + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.7.tgz", + "integrity": "sha512-t7v8ZPxhhKgOKtU+uyJT13lu4vL7az5aFi4IdoDs/eS548edn2M8Ik9h8fxgvMjGoAUVFSt6ZC1P5cWmQ014QQ==", + "license": "MIT", + "dependencies": { + "immer": "^9.0.21", + "redux": "^4.2.1", + "redux-thunk": "^2.4.2", + "reselect": "^4.1.8" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18", + "react-redux": "^7.2.1 || ^8.0.2" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz", + "integrity": "sha512-vDbaOzF7yT2Qs4vO6XV1MHcJv+3dgR1sT+l3B8xxOVhUC336prMvqrvsLL/9Dnw2xr6Qhz4J0dmS0llNAbnUmQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "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/@sinclair/typebox": { + "version": "0.34.41", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", + "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-6.5.1.tgz", + "integrity": "sha512-9PYGcXrAxitycIjRmZB+Q0JaN07GZIWaTBIGQzfaZv+qr1n8X1XUEJ5rZ/vx6OVD9RRYlrNnXWExQXcmZeD/BQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", + "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-6.5.1.tgz", + "integrity": "sha512-8DPaVVE3fd5JKuIC29dqyMB54sA6mfgki2H2+swh+zNJoynC8pMPzOkidqHOSc6Wj032fhl8Z0TVn1GiPpAiJg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-6.5.1.tgz", + "integrity": "sha512-FwOEi0Il72iAzlkaHrlemVurgSQRDFbk0OC8dSvD5fSBPHltNh7JtLsxmZUhjYBZo2PpcU/RJvvi6Q0l7O7ogw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-6.5.1.tgz", + "integrity": "sha512-gWGsiwjb4tw+ITOJ86ndY/DZZ6cuXMNE/SjcDRg+HLuCmwpcjOktwRF9WgAiycTqJD/QXqL2f8IzE2Rzh7aVXA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-6.5.1.tgz", + "integrity": "sha512-2jT3nTayyYP7kI6aGutkyfJ7UMGtuguD72OjeGLwVNyfPRBD8zQthlvL+fAbAKk5n9ZNcvFkp/b1lZ7VsYqVJg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-6.5.1.tgz", + "integrity": "sha512-a1p6LF5Jt33O3rZoVRBqdxL350oge54iZWHNI6LJB5tQ7EelvD/Mb1mfBiZNAan0dt4i3VArkFRjA4iObuNykQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-preset": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-6.5.1.tgz", + "integrity": "sha512-6127fvO/FF2oi5EzSQOAjo1LE3OtNVh11R+/8FXa+mHx1ptAaS4cknIjnUA7e6j6fwGGJ17NzaTJFUwOV2zwCw==", + "license": "MIT", + "dependencies": { + "@svgr/babel-plugin-add-jsx-attribute": "^6.5.1", + "@svgr/babel-plugin-remove-jsx-attribute": "*", + "@svgr/babel-plugin-remove-jsx-empty-expression": "*", + "@svgr/babel-plugin-replace-jsx-attribute-value": "^6.5.1", + "@svgr/babel-plugin-svg-dynamic-title": "^6.5.1", + "@svgr/babel-plugin-svg-em-dimensions": "^6.5.1", + "@svgr/babel-plugin-transform-react-native-svg": "^6.5.1", + "@svgr/babel-plugin-transform-svg-component": "^6.5.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/core": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-6.5.1.tgz", + "integrity": "sha512-/xdLSWxK5QkqG524ONSjvg3V/FkNyCv538OIBdQqPNaAta3AsXj/Bd2FbvR87yMbXO2hFSWiAe/Q6IkVPDw+mw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "^7.19.6", + "@svgr/babel-preset": "^6.5.1", + "@svgr/plugin-jsx": "^6.5.1", + "camelcase": "^6.2.0", + "cosmiconfig": "^7.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/hast-util-to-babel-ast": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-6.5.1.tgz", + "integrity": "sha512-1hnUxxjd83EAxbL4a0JDJoD3Dao3hmjvyvyEV8PzWmLK3B9m9NPlW7GKjFyoWE8nM7HnXzPcmmSyOW8yOddSXw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.0", + "entities": "^4.4.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/hast-util-to-babel-ast/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@svgr/plugin-jsx": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-6.5.1.tgz", + "integrity": "sha512-+UdQxI3jgtSjCykNSlEMuy1jSRQlGC7pqBCPvkG/2dATdWo082zHTTK3uhnAju2/6XpE6B5mZ3z4Z8Ns01S8Gw==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.19.6", + "@svgr/babel-preset": "^6.5.1", + "@svgr/hast-util-to-babel-ast": "^6.5.1", + "svg-parser": "^2.0.4" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "^6.0.0" + } + }, + "node_modules/@testing-library/dom": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", + "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.1.3", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "deep-equal": "^2.0.5" + } + }, + "node_modules/@testing-library/dom/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/@testing-library/jest-dom": { + "version": "5.17.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz", + "integrity": "sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.0.1", + "@babel/runtime": "^7.9.2", + "@types/testing-library__jest-dom": "^5.9.1", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.5.6", + "lodash": "^4.17.15", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=8", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/react": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-13.4.0.tgz", + "integrity": "sha512-sXOGON+WNTh3MLE9rve97ftaZukN3oNf2KjDy7YTx6hcTO2uuLHuCGynMDhFwGw/jYf4OJ2Qk0i4i79qMNNkyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^8.5.0", + "@types/react-dom": "^18.0.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "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" + }, + "node_modules/@types/chai": { + "version": "4.3.20", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz", + "integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai-subset": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.6.tgz", + "integrity": "sha512-m8lERkkQj+uek18hXOZuec3W/fCRTrU4hrnXjH3qhHy96ytuPaPiWGgu7sJb7tZxZonO75vYAjCvpe/e4VUwRw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/chai": "<5.2.0" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/@types/events": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz", + "integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz", + "integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==", + "license": "MIT", + "dependencies": { + "hoist-non-react-statics": "^3.3.0" + }, + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/jest/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", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@types/jest/node_modules/pretty-format": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/papaparse": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.0.tgz", + "integrity": "sha512-GVs5iMQmUr54BAZYYkByv8zPofFxmyxUpISPb2oh8sayR3+1zbxasrOvoKiHJ/nnoq/uULuPsu1Lze1EkagVFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-virtualized-auto-sizer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.4.tgz", + "integrity": "sha512-nhYwlFiYa8M3S+O2T9QO/e1FQUYMr/wJENUdf/O0dhRi1RS/93rjrYQFYdbUqtdFySuhrtnEDX29P6eKOttY+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/react-window": { + "version": "1.8.8", + "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz", + "integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/testing-library__jest-dom": { + "version": "5.14.9", + "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.9.tgz", + "integrity": "sha512-FSYhIjFlfOpGSRyVoMBMuS3ws5ehFQODymf3vlI7U1K8c7PHwWwFY7VREfmsuzHSOnoKs/9/Y983ayOs7eRzqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jest": "*" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", + "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==", + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-2.2.0.tgz", + "integrity": "sha512-FFpefhvExd1toVRlokZgxgy2JtnBOdp4ZDsq7ldCWaqGSGn9UhWMAVm/1lxPL14JfNS5yGz+s9yFrQY6shoStA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.19.6", + "@babel/plugin-transform-react-jsx": "^7.19.0", + "@babel/plugin-transform-react-jsx-development": "^7.18.6", + "@babel/plugin-transform-react-jsx-self": "^7.18.6", + "@babel/plugin-transform-react-jsx-source": "^7.19.6", + "magic-string": "^0.26.7", + "react-refresh": "^0.14.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^3.0.0" + } + }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-globals": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", + "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.1.0", + "acorn-walk": "^8.0.2" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "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/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/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/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "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/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz", + "integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "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", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "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==", + "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/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001757", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", + "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", + "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": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/common": { + "resolved": "../common-front", + "link": true + }, + "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==", + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/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/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssom": "~0.3.6" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cssstyle/node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "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==", + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", + "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "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/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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-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/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "license": "Apache-2.0", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "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" + }, + "node_modules/domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "license": "MIT", + "dependencies": { + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "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==", + "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.262", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.262.tgz", + "integrity": "sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==", + "license": "ISC" + }, + "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/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "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==", + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "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==", + "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/esbuild": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.15.18.tgz", + "integrity": "sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.15.18", + "@esbuild/linux-loong64": "0.15.18", + "esbuild-android-64": "0.15.18", + "esbuild-android-arm64": "0.15.18", + "esbuild-darwin-64": "0.15.18", + "esbuild-darwin-arm64": "0.15.18", + "esbuild-freebsd-64": "0.15.18", + "esbuild-freebsd-arm64": "0.15.18", + "esbuild-linux-32": "0.15.18", + "esbuild-linux-64": "0.15.18", + "esbuild-linux-arm": "0.15.18", + "esbuild-linux-arm64": "0.15.18", + "esbuild-linux-mips64le": "0.15.18", + "esbuild-linux-ppc64le": "0.15.18", + "esbuild-linux-riscv64": "0.15.18", + "esbuild-linux-s390x": "0.15.18", + "esbuild-netbsd-64": "0.15.18", + "esbuild-openbsd-64": "0.15.18", + "esbuild-sunos-64": "0.15.18", + "esbuild-windows-32": "0.15.18", + "esbuild-windows-64": "0.15.18", + "esbuild-windows-arm64": "0.15.18" + } + }, + "node_modules/esbuild-android-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.15.18.tgz", + "integrity": "sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-android-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.15.18.tgz", + "integrity": "sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-darwin-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.15.18.tgz", + "integrity": "sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-darwin-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.18.tgz", + "integrity": "sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-freebsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.18.tgz", + "integrity": "sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-freebsd-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.18.tgz", + "integrity": "sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-32": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.15.18.tgz", + "integrity": "sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.15.18.tgz", + "integrity": "sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-arm": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.15.18.tgz", + "integrity": "sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.18.tgz", + "integrity": "sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-mips64le": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.18.tgz", + "integrity": "sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-ppc64le": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.18.tgz", + "integrity": "sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-riscv64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.18.tgz", + "integrity": "sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-s390x": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.18.tgz", + "integrity": "sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-netbsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.18.tgz", + "integrity": "sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-openbsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.18.tgz", + "integrity": "sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-sunos-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.15.18.tgz", + "integrity": "sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-32": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.15.18.tgz", + "integrity": "sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.15.18.tgz", + "integrity": "sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.18.tgz", + "integrity": "sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "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/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==", + "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/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/expect": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.2.0", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/fancy-canvas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz", + "integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "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/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "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==", + "license": "MIT", + "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/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==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "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==", + "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==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "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-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "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==", + "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==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/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==", + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/immer": { + "version": "9.0.21", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", + "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/immutable": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", + "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/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/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "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-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-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "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-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "license": "MIT", + "optional": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "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-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-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/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/jest-diff": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", + "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-diff/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/jest-diff/node_modules/pretty-format": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-diff/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", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-diff/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-matcher-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", + "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.2.0", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils/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/jest-matcher-utils/node_modules/pretty-format": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils/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", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-matcher-utils/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-message-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", + "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.2.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util/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/jest-message-util/node_modules/pretty-format": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util/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", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-message-util/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-mock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", + "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", + "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util/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/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", + "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "abab": "^2.0.6", + "acorn": "^8.8.1", + "acorn-globals": "^7.0.0", + "cssom": "^0.5.0", + "cssstyle": "^2.3.0", + "data-urls": "^3.0.2", + "decimal.js": "^10.4.2", + "domexception": "^4.0.0", + "escodegen": "^2.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.2", + "parse5": "^7.1.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0", + "ws": "^8.11.0", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightweight-charts": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-4.2.3.tgz", + "integrity": "sha512-5kS/2hY3wNYNzhnS8Gb+GAS07DX8GPF2YVDnd2NMC85gJVQ6RLU6YrXNgNJ6eg0AnWPwCnvaGtYmGky3HiLQEw==", + "license": "Apache-2.0", + "dependencies": { + "fancy-canvas": "2.1.0" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/local-pkg": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.3.tgz", + "integrity": "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "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==", + "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", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.26.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.26.7.tgz", + "integrity": "sha512-hX9XH3ziStPoPhJxLq1syWuZMxbDvGNbVchfrdCtanC7D13888bMFow61x8axrx+GfHLtVeAx2kxL7tTGRl+Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + }, + "engines": { + "node": ">=12" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", + "integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^14 || ^16 || >=18" + } + }, + "node_modules/nock": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/nock/-/nock-13.5.6.tgz", + "integrity": "sha512-o2zOYiCpzRqSzPj0Zt/dQ/DqZeYoaQ7TUonc/xUPjCGl9WeHpNbxgVvOquXYAaJzI0M9BXV3HTzG0p8IUAbBTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "json-stringify-safe": "^5.0.1", + "propagate": "^2.0.0" + }, + "engines": { + "node": ">= 10.13" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT", + "optional": true + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "license": "MIT" + }, + "node_modules/nwsapi": { + "version": "2.2.22", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz", + "integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==", + "dev": true, + "license": "MIT" + }, + "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-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "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/papaparse": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", + "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", + "license": "MIT" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "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/postcss/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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/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", + "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", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "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/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-icons": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.12.0.tgz", + "integrity": "sha512-IBaDuHiShdZqmfc/TwHu6+d6k2ltNCf3AszxNmjJc1KUfXdEeRJOKyNvLmAHaarhzGmTSVygNdyu8/opXv2gaw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, + "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" + }, + "node_modules/react-redux": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz", + "integrity": "sha512-n0ZrutD7DaX/j9VscF+uTALI3oUPa/pO4Z3soOBIjuRn/FzVu6aehhysxZCLi6y7duMf52WNZGMl7CtuK5EnRw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.12.1", + "@types/hoist-non-react-statics": "^3.3.1", + "@types/use-sync-external-store": "^0.0.3", + "hoist-non-react-statics": "^3.3.2", + "react-is": "^18.0.0", + "use-sync-external-store": "^1.0.0" + }, + "peerDependencies": { + "@types/react": "^16.8 || ^17.0 || ^18.0", + "@types/react-dom": "^16.8 || ^17.0 || ^18.0", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0", + "react-native": ">=0.59", + "redux": "^4 || ^5.0.0-beta.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-redux/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz", + "integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.2.tgz", + "integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.1", + "react-router": "6.30.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-virtualized-auto-sizer": { + "version": "1.0.26", + "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.26.tgz", + "integrity": "sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A==", + "license": "MIT", + "peerDependencies": { + "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-window": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz", + "integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.0.0", + "memoize-one": ">=3.1.1 <6" + }, + "engines": { + "node": ">8.0.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "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==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "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/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, + "node_modules/redux-thunk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz", + "integrity": "sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==", + "license": "MIT", + "peerDependencies": { + "redux": "^4" + } + }, + "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/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/reselect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", + "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==", + "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==", + "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-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "2.79.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", + "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", + "license": "MIT", + "peer": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "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/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sass": { + "version": "1.94.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.94.2.tgz", + "integrity": "sha512-N+7WK20/wOr7CzA2snJcUSSNTCzeCGUTFY3OgeQP3mZ1aj9NMQ0mSTXwlrnd89j33zzQJGqIN52GIOmYrfq46A==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "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.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "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/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.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "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/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true, + "license": "MIT" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "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/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/strip-literal": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-1.3.0.tgz", + "integrity": "sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "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/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-parser": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", + "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", + "license": "MIT" + }, + "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/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/tinypool": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.3.1.tgz", + "integrity": "sha512-zLA1ZXlstbU2rlpA4CIeVaqvWq41MTWqLY3FfsAXgC8+f7Pk7zroaJQxDgxn1xNudKW6Kmj4808rPFShUlIRmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-1.1.1.tgz", + "integrity": "sha512-UVq5AXt/gQlti7oxoIg5oi/9r0WpF7DGEVwXgqWSMmyN16+e3tl5lIvTaOpJ3TAtu5xFzWccFRM4R5NaWHF+4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "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/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.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/vite": { + "version": "3.2.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.11.tgz", + "integrity": "sha512-K/jGKL/PgbIgKCiJo5QbASQhFiV02X9Jh+Qq0AKCRCRKZtOTVi4t6wh75FDpGf2N9rYOnzH87OEFQNaFy6pdxQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.15.9", + "postcss": "^8.4.18", + "resolve": "^1.22.1", + "rollup": "^2.79.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-plugin-svgr": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-2.4.0.tgz", + "integrity": "sha512-q+mJJol6ThvqkkJvvVFEndI4EaKIjSI0I3jNFgSoC9fXAz1M7kYTVUin8fhUsFojFDKZ9VHKtX6NXNaOLpbsHA==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.2", + "@svgr/core": "^6.5.1" + }, + "peerDependencies": { + "vite": "^2.6.0 || 3 || 4" + } + }, + "node_modules/vite-tsconfig-paths": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-4.3.2.tgz", + "integrity": "sha512-0Vd/a6po6Q+86rPlntHye7F31zA2URZMbH8M3saAZ/xR9QoGN/L21bxEGfXdWmFdNkqPpRdxFT7nmNe12e9/uA==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "globrex": "^0.1.2", + "tsconfck": "^3.0.3" + }, + "peerDependencies": { + "vite": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vite-tsconfig-paths/node_modules/tsconfck": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vite-tsconfig-paths/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vitest": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.25.8.tgz", + "integrity": "sha512-X75TApG2wZTJn299E/TIYevr4E9/nBo1sUtZzn0Ci5oK8qnpZAZyhwg0qCeMSakGIWtc6oRwcQFyFfW14aOFWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^4.3.4", + "@types/chai-subset": "^1.3.3", + "@types/node": "*", + "acorn": "^8.8.1", + "acorn-walk": "^8.2.0", + "chai": "^4.3.7", + "debug": "^4.3.4", + "local-pkg": "^0.4.2", + "source-map": "^0.6.1", + "strip-literal": "^1.0.0", + "tinybench": "^2.3.1", + "tinypool": "^0.3.0", + "tinyspy": "^1.0.2", + "vite": "^3.0.0 || ^4.0.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": ">=v14.16.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@vitest/browser": "*", + "@vitest/ui": "*", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "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-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.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "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/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "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/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12" + } + }, + "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/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/zustymiddleware": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/zustymiddleware/-/zustymiddleware-1.2.0.tgz", + "integrity": "sha512-N2/OrW3r5TwX5slwSwk81xynDnM24pWkcIjvoHDeGJJY/fKrfKjZ4EmvvADKANeMPkkCp4Mkf49Ok2BhDR0scg==", + "license": "MIT" + }, + "node_modules/zustymiddlewarets": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/zustymiddlewarets/-/zustymiddlewarets-1.4.2.tgz", + "integrity": "sha512-fwXF02TgFtrtxSwgyQg/mlFGU1lDC8bgDDQiKTARt2TqC508jTiBD/6ztb0yZ4Qp+fVBjfbOJN1JLx2PXNW+HQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.5.1" + } + } + } +} diff --git a/ethernet-view/package.json b/ethernet-view/package.json new file mode 100644 index 000000000..6652f8ea0 --- /dev/null +++ b/ethernet-view/package.json @@ -0,0 +1,57 @@ +{ + "name": "frontend-h8", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "test": "vitest", + "preview": "npm run build && vite preview" + }, + "dependencies": { + "@canvasjs/react-charts": "^1.0.0", + "@react-spring/web": "^9.7.2", + "@reduxjs/toolkit": "^1.9.0", + "axios": "^1.1.3", + "common": "file:../common-front", + "dotenv": "^16.0.3", + "events": "^3.3.0", + "lightweight-charts": "^4.1.2", + "lodash": "^4.17.21", + "nanoid": "^4.0.2", + "papaparse": "^5.4.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-icons": "^4.6.0", + "react-redux": "^8.0.5", + "react-router-dom": "^6.22.3", + "react-use-measure": "^2.1.1", + "react-virtualized-auto-sizer": "^1.0.7", + "react-window": "^1.8.8", + "vite-plugin-svgr": "^2.4.0", + "vite-tsconfig-paths": "^4.0.5", + "zustand": "^4.4.6", + "zustymiddleware": "^1.2.0", + "zustymiddlewarets": "^1.4.2" + }, + "devDependencies": { + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^13.4.0", + "@types/events": "^3.0.0", + "@types/lodash": "^4.14.191", + "@types/node": "^18.11.9", + "@types/papaparse": "^5.3.14", + "@types/react": "^18.0.24", + "@types/react-dom": "^18.0.8", + "@types/react-virtualized-auto-sizer": "^1.0.1", + "@types/react-window": "^1.8.5", + "@vitejs/plugin-react": "^2.2.0", + "jsdom": "^20.0.2", + "nock": "^13.2.9", + "sass": "^1.56.1", + "typescript": "^4.6.4", + "vite": "^3.2.10", + "vitest": "^0.25.2" + } +} diff --git a/ethernet-view/pod_navigation.md b/ethernet-view/pod_navigation.md new file mode 100644 index 000000000..c6771d163 --- /dev/null +++ b/ethernet-view/pod_navigation.md @@ -0,0 +1,11 @@ +# Pod Navigation System + +This document defines the components and backend modules that will make dynamic point-to-point navigation posible. + +## Protocol + +When the VCU is `IDLE`, one of the available orders is `start_traction`. If we hit that, the next state order we receive is `custom_route`. This order acceptes an unlimited number of position + velocity pairs, which indicate the route the vehicle is going to follow. To perform this, the frontend will send an order which follows a different structure from what the other orders are. + +## Frontend + +The frontend will actually send separate orders. diff --git a/ethernet-view/public/vite.svg b/ethernet-view/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/ethernet-view/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ethernet-view/src/App.css b/ethernet-view/src/App.css new file mode 100644 index 000000000..f1a98c5ef --- /dev/null +++ b/ethernet-view/src/App.css @@ -0,0 +1,6 @@ +.App { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; +} diff --git a/ethernet-view/src/App.tsx b/ethernet-view/src/App.tsx new file mode 100644 index 000000000..d29e32ce3 --- /dev/null +++ b/ethernet-view/src/App.tsx @@ -0,0 +1,60 @@ +import "./App.css"; +import { TestingPage } from "pages/TestingPage/TestingPage"; +import { SplashScreen } from "components/SplashScreen/SplashScreen"; +import { WsHandlerProvider } from "common"; +import { useLoadBackend } from "common"; +import { AppLayout } from "layouts/AppLayout/AppLayout"; +import { useState, useEffect } from "react"; +import { LoggerPage } from "pages/LoggerPage/LoggerPage"; +import { CamerasPage } from "pages/CamerasPage/CamerasPage"; + +function App() { + + const isProduction = import.meta.env.PROD; + const loadBackend = useLoadBackend(isProduction); + const [pageShown, setPageShown] = useState("testing"); + + useEffect(() => { + const savedTheme = localStorage.getItem("theme") || "light"; + document.documentElement.setAttribute("data-theme", savedTheme); + }, []); + + return ( + +
+ {loadBackend.state === "fulfilled" && + + + } + {loadBackend.state === "pending" && } + {loadBackend.state === "rejected" &&
{`${loadBackend.error}`}
} +
+
+ +
+
+ +
+
+ ); +} + +export default App; \ No newline at end of file diff --git a/ethernet-view/src/BackendTypes.ts b/ethernet-view/src/BackendTypes.ts new file mode 100644 index 000000000..5b811b3e9 --- /dev/null +++ b/ethernet-view/src/BackendTypes.ts @@ -0,0 +1,44 @@ +export type BackendType = NumericType | Bool | Enum; + +export type NumericType = + | SignedIntegerType + | UnsignedIntegerType + | FloatingType; + +export type SignedIntegerType = "int8" | "int16" | "int32" | "int64"; + +export type UnsignedIntegerType = "uint8" | "uint16" | "uint32" | "uint64"; + +export type FloatingType = "float32" | "float64"; + +export function isNumericType(type: string): type is NumericType { + return ( + isUnsignedIntegerType(type) || + isSignedIntegerType(type) || + isFloatingType(type) + ); +} + +export function isUnsignedIntegerType( + type: string +): type is UnsignedIntegerType { + return ( + type == "uint8" || + type == "uint16" || + type == "uint32" || + type == "uint64" + ); +} + +export function isSignedIntegerType(type: string): type is SignedIntegerType { + return ( + type == "int8" || type == "int16" || type == "int32" || type == "int64" + ); +} + +export function isFloatingType(type: string): type is FloatingType { + return type == "float32" || type == "float64"; +} + +type Enum = "enum"; +type Bool = "bool"; diff --git a/ethernet-view/src/assets/fonts/Consolas/Consolas-Bold-Italic.ttf b/ethernet-view/src/assets/fonts/Consolas/Consolas-Bold-Italic.ttf new file mode 100644 index 000000000..d9df2110f Binary files /dev/null and b/ethernet-view/src/assets/fonts/Consolas/Consolas-Bold-Italic.ttf differ diff --git a/ethernet-view/src/assets/fonts/Consolas/Consolas-Bold.ttf b/ethernet-view/src/assets/fonts/Consolas/Consolas-Bold.ttf new file mode 100644 index 000000000..77f5d6052 Binary files /dev/null and b/ethernet-view/src/assets/fonts/Consolas/Consolas-Bold.ttf differ diff --git a/ethernet-view/src/assets/fonts/Consolas/Consolas-Italic.ttf b/ethernet-view/src/assets/fonts/Consolas/Consolas-Italic.ttf new file mode 100644 index 000000000..2de4de8a9 Binary files /dev/null and b/ethernet-view/src/assets/fonts/Consolas/Consolas-Italic.ttf differ diff --git a/ethernet-view/src/assets/fonts/Consolas/Consolas.scss b/ethernet-view/src/assets/fonts/Consolas/Consolas.scss new file mode 100644 index 000000000..fb1bd23d7 --- /dev/null +++ b/ethernet-view/src/assets/fonts/Consolas/Consolas.scss @@ -0,0 +1,27 @@ +@font-face { + font-family: Consolas; + src: url("/src/assets/fonts/Consolas/Consolas.ttf"); + font-weight: normal; + font-style: normal; +} + +@font-face { + font-family: Consolas; + src: url("/src/assets/fonts/Consolas/Consolas-Bold.ttf"); + font-weight: bold; + font-style: normal; +} + +@font-face { + font-family: Consolas; + src: url("/src/assets/fonts/Consolas/Consolas-Italic.ttf"); + font-weight: normal; + font-style: italic; +} + +@font-face { + font-family: Consolas; + src: url("/src/assets/fonts/Consolas/Consolas-Bold-Italic.ttf"); + font-weight: bold; + font-style: italic; +} diff --git a/ethernet-view/src/assets/fonts/Consolas/Consolas.ttf b/ethernet-view/src/assets/fonts/Consolas/Consolas.ttf new file mode 100644 index 000000000..e881ca4b5 Binary files /dev/null and b/ethernet-view/src/assets/fonts/Consolas/Consolas.ttf differ diff --git a/ethernet-view/src/assets/fonts/Inter/Inter-Black.ttf b/ethernet-view/src/assets/fonts/Inter/Inter-Black.ttf new file mode 100644 index 000000000..e284fa005 Binary files /dev/null and b/ethernet-view/src/assets/fonts/Inter/Inter-Black.ttf differ diff --git a/ethernet-view/src/assets/fonts/Inter/Inter-Bold.ttf b/ethernet-view/src/assets/fonts/Inter/Inter-Bold.ttf new file mode 100644 index 000000000..f13d511d8 Binary files /dev/null and b/ethernet-view/src/assets/fonts/Inter/Inter-Bold.ttf differ diff --git a/ethernet-view/src/assets/fonts/Inter/Inter-ExtraBold.ttf b/ethernet-view/src/assets/fonts/Inter/Inter-ExtraBold.ttf new file mode 100644 index 000000000..2b55fc13f Binary files /dev/null and b/ethernet-view/src/assets/fonts/Inter/Inter-ExtraBold.ttf differ diff --git a/ethernet-view/src/assets/fonts/Inter/Inter-ExtraLight.ttf b/ethernet-view/src/assets/fonts/Inter/Inter-ExtraLight.ttf new file mode 100644 index 000000000..af2bfbb32 Binary files /dev/null and b/ethernet-view/src/assets/fonts/Inter/Inter-ExtraLight.ttf differ diff --git a/ethernet-view/src/assets/fonts/Inter/Inter-Light.ttf b/ethernet-view/src/assets/fonts/Inter/Inter-Light.ttf new file mode 100644 index 000000000..34546cfd7 Binary files /dev/null and b/ethernet-view/src/assets/fonts/Inter/Inter-Light.ttf differ diff --git a/ethernet-view/src/assets/fonts/Inter/Inter-Medium.ttf b/ethernet-view/src/assets/fonts/Inter/Inter-Medium.ttf new file mode 100644 index 000000000..9a3396fc4 Binary files /dev/null and b/ethernet-view/src/assets/fonts/Inter/Inter-Medium.ttf differ diff --git a/ethernet-view/src/assets/fonts/Inter/Inter-Regular.ttf b/ethernet-view/src/assets/fonts/Inter/Inter-Regular.ttf new file mode 100644 index 000000000..2c164bb2d Binary files /dev/null and b/ethernet-view/src/assets/fonts/Inter/Inter-Regular.ttf differ diff --git a/ethernet-view/src/assets/fonts/Inter/Inter-SemiBold.ttf b/ethernet-view/src/assets/fonts/Inter/Inter-SemiBold.ttf new file mode 100644 index 000000000..b97437120 Binary files /dev/null and b/ethernet-view/src/assets/fonts/Inter/Inter-SemiBold.ttf differ diff --git a/ethernet-view/src/assets/fonts/Inter/Inter-Thin.ttf b/ethernet-view/src/assets/fonts/Inter/Inter-Thin.ttf new file mode 100644 index 000000000..7f5b005a2 Binary files /dev/null and b/ethernet-view/src/assets/fonts/Inter/Inter-Thin.ttf differ diff --git a/ethernet-view/src/assets/fonts/Inter/Inter.scss b/ethernet-view/src/assets/fonts/Inter/Inter.scss new file mode 100644 index 000000000..febd6611b --- /dev/null +++ b/ethernet-view/src/assets/fonts/Inter/Inter.scss @@ -0,0 +1,62 @@ +@font-face { + font-family: Inter; + src: url("/src/assets/fonts/Inter/Inter-Thin.ttf"); + font-weight: 100; + font-style: normal; +} + +@font-face { + font-family: Inter; + src: url("/src/assets/fonts/Inter/Inter-ExtraLight.ttf"); + font-weight: 200; + font-style: normal; +} + +@font-face { + font-family: Inter; + src: url("/src/assets/fonts/Inter/Inter-Light.ttf"); + font-weight: 300; + font-style: normal; +} + +@font-face { + font-family: Inter; + src: url("/src/assets/fonts/Inter/Inter-Regular.ttf"); + font-weight: 400; + font-style: normal; +} + +@font-face { + font-family: Inter; + src: url("/src/assets/fonts/Inter/Inter-Medium.ttf"); + font-weight: 500; + font-style: normal; +} + +@font-face { + font-family: Inter; + src: url("/src/assets/fonts/Inter/Inter-SemiBold.ttf"); + font-weight: 600; + font-style: normal; +} + +@font-face { + font-family: Inter; + src: url("/src/assets/fonts/Inter/Inter-Bold.ttf"); + font-weight: 700; + font-style: normal; +} + +@font-face { + font-family: Inter; + src: url("/src/assets/fonts/Inter/Inter-ExtraBold.ttf"); + font-weight: 800; + font-style: normal; +} + +@font-face { + font-family: Inter; + src: url("/src/assets/fonts/Inter/Inter-ExtraBold.ttf"); + font-weight: 900; + font-style: normal; +} diff --git a/ethernet-view/src/assets/fonts/NotoColorEmoji/NotoColorEmoji.scss b/ethernet-view/src/assets/fonts/NotoColorEmoji/NotoColorEmoji.scss new file mode 100644 index 000000000..7f0545e47 --- /dev/null +++ b/ethernet-view/src/assets/fonts/NotoColorEmoji/NotoColorEmoji.scss @@ -0,0 +1,4 @@ +@font-face { + font-family: NotoColorEmoji; + src: url("/src/assets/fonts/NotoColorEmoji/NotoColorEmoji.ttf"); +} diff --git a/ethernet-view/src/assets/fonts/NotoColorEmoji/NotoColorEmoji.ttf b/ethernet-view/src/assets/fonts/NotoColorEmoji/NotoColorEmoji.ttf new file mode 100644 index 000000000..d21205d9a Binary files /dev/null and b/ethernet-view/src/assets/fonts/NotoColorEmoji/NotoColorEmoji.ttf differ diff --git a/ethernet-view/src/assets/svg/binary-file.svg b/ethernet-view/src/assets/svg/binary-file.svg new file mode 100644 index 000000000..82741071a --- /dev/null +++ b/ethernet-view/src/assets/svg/binary-file.svg @@ -0,0 +1,23 @@ + + + + + + + + + diff --git a/ethernet-view/src/assets/svg/camera.svg b/ethernet-view/src/assets/svg/camera.svg new file mode 100644 index 000000000..35be6eba3 --- /dev/null +++ b/ethernet-view/src/assets/svg/camera.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/ethernet-view/src/assets/svg/chart.svg b/ethernet-view/src/assets/svg/chart.svg new file mode 100644 index 000000000..78f13281f --- /dev/null +++ b/ethernet-view/src/assets/svg/chart.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ethernet-view/src/assets/svg/close-folder.svg b/ethernet-view/src/assets/svg/close-folder.svg new file mode 100644 index 000000000..69b4be200 --- /dev/null +++ b/ethernet-view/src/assets/svg/close-folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/ethernet-view/src/assets/svg/connection.svg b/ethernet-view/src/assets/svg/connection.svg new file mode 100644 index 000000000..804e56fd7 --- /dev/null +++ b/ethernet-view/src/assets/svg/connection.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ethernet-view/src/assets/svg/cross-active.svg b/ethernet-view/src/assets/svg/cross-active.svg new file mode 100644 index 000000000..9a036ccc6 --- /dev/null +++ b/ethernet-view/src/assets/svg/cross-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ethernet-view/src/assets/svg/cross.svg b/ethernet-view/src/assets/svg/cross.svg new file mode 100644 index 000000000..c0b33dcd0 --- /dev/null +++ b/ethernet-view/src/assets/svg/cross.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ethernet-view/src/assets/svg/fault.svg b/ethernet-view/src/assets/svg/fault.svg new file mode 100644 index 000000000..8e1c800bc --- /dev/null +++ b/ethernet-view/src/assets/svg/fault.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/ethernet-view/src/assets/svg/folder-closed.svg b/ethernet-view/src/assets/svg/folder-closed.svg new file mode 100644 index 000000000..bccc3b366 --- /dev/null +++ b/ethernet-view/src/assets/svg/folder-closed.svg @@ -0,0 +1,3 @@ + + + diff --git a/ethernet-view/src/assets/svg/folder-open.svg b/ethernet-view/src/assets/svg/folder-open.svg new file mode 100644 index 000000000..56e6cd6b0 --- /dev/null +++ b/ethernet-view/src/assets/svg/folder-open.svg @@ -0,0 +1,3 @@ + + + diff --git a/ethernet-view/src/assets/svg/incoming-message.svg b/ethernet-view/src/assets/svg/incoming-message.svg new file mode 100644 index 000000000..f07b00d68 --- /dev/null +++ b/ethernet-view/src/assets/svg/incoming-message.svg @@ -0,0 +1,3 @@ + + + diff --git a/ethernet-view/src/assets/svg/info.svg b/ethernet-view/src/assets/svg/info.svg new file mode 100644 index 000000000..ec4360ee1 --- /dev/null +++ b/ethernet-view/src/assets/svg/info.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ethernet-view/src/assets/svg/letter.svg b/ethernet-view/src/assets/svg/letter.svg new file mode 100644 index 000000000..b63895d54 --- /dev/null +++ b/ethernet-view/src/assets/svg/letter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ethernet-view/src/assets/svg/logger.svg b/ethernet-view/src/assets/svg/logger.svg new file mode 100644 index 000000000..a4b5a560b --- /dev/null +++ b/ethernet-view/src/assets/svg/logger.svg @@ -0,0 +1,3 @@ + + + diff --git a/ethernet-view/src/assets/svg/logs.svg b/ethernet-view/src/assets/svg/logs.svg new file mode 100644 index 000000000..3df71cd6c --- /dev/null +++ b/ethernet-view/src/assets/svg/logs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ethernet-view/src/assets/svg/mokey-face.svg b/ethernet-view/src/assets/svg/mokey-face.svg new file mode 100644 index 000000000..11df02b8b --- /dev/null +++ b/ethernet-view/src/assets/svg/mokey-face.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ethernet-view/src/assets/svg/monkey.svg b/ethernet-view/src/assets/svg/monkey.svg new file mode 100644 index 000000000..c0e505d88 --- /dev/null +++ b/ethernet-view/src/assets/svg/monkey.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + diff --git a/ethernet-view/src/assets/svg/oscilloscope.svg b/ethernet-view/src/assets/svg/oscilloscope.svg new file mode 100644 index 000000000..31b666f08 --- /dev/null +++ b/ethernet-view/src/assets/svg/oscilloscope.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ethernet-view/src/assets/svg/outgoing-message.svg b/ethernet-view/src/assets/svg/outgoing-message.svg new file mode 100644 index 000000000..51458f753 --- /dev/null +++ b/ethernet-view/src/assets/svg/outgoing-message.svg @@ -0,0 +1,3 @@ + + + diff --git a/ethernet-view/src/assets/svg/paper-airplane.svg b/ethernet-view/src/assets/svg/paper-airplane.svg new file mode 100644 index 000000000..8ffdea69e --- /dev/null +++ b/ethernet-view/src/assets/svg/paper-airplane.svg @@ -0,0 +1,3 @@ + + + diff --git a/ethernet-view/src/assets/svg/right-arrow.svg b/ethernet-view/src/assets/svg/right-arrow.svg new file mode 100644 index 000000000..2647c340c --- /dev/null +++ b/ethernet-view/src/assets/svg/right-arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/ethernet-view/src/assets/svg/target.svg b/ethernet-view/src/assets/svg/target.svg new file mode 100644 index 000000000..bc2af3c13 --- /dev/null +++ b/ethernet-view/src/assets/svg/target.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ethernet-view/src/assets/svg/team_logo.svg b/ethernet-view/src/assets/svg/team_logo.svg new file mode 100644 index 000000000..1bba6ddb0 --- /dev/null +++ b/ethernet-view/src/assets/svg/team_logo.svg @@ -0,0 +1,37 @@ + + + + + + diff --git a/ethernet-view/src/assets/svg/testing.svg b/ethernet-view/src/assets/svg/testing.svg new file mode 100644 index 000000000..efb3860d7 --- /dev/null +++ b/ethernet-view/src/assets/svg/testing.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ethernet-view/src/assets/svg/warning.svg b/ethernet-view/src/assets/svg/warning.svg new file mode 100644 index 000000000..821e6e711 --- /dev/null +++ b/ethernet-view/src/assets/svg/warning.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ethernet-view/src/components/BootloaderContainer/Bootloader/Bootloader.module.scss b/ethernet-view/src/components/BootloaderContainer/Bootloader/Bootloader.module.scss new file mode 100644 index 000000000..2959362cf --- /dev/null +++ b/ethernet-view/src/components/BootloaderContainer/Bootloader/Bootloader.module.scss @@ -0,0 +1,15 @@ +@use "src/styles/styles"; + +.bootloader { + height: 10rem; + display: flex; + align-items: stretch; + justify-content: stretch; + @include styles.alternate-code-text; + gap: 1rem; + font-size: 1.1rem; + padding: 1px; + + overflow-x: auto; + overflow-y: hidden; +} diff --git a/ethernet-view/src/components/BootloaderContainer/Bootloader/Bootloader.tsx b/ethernet-view/src/components/BootloaderContainer/Bootloader/Bootloader.tsx new file mode 100644 index 000000000..5f6c5db25 --- /dev/null +++ b/ethernet-view/src/components/BootloaderContainer/Bootloader/Bootloader.tsx @@ -0,0 +1,51 @@ +import styles from "./Bootloader.module.scss"; +import { useState } from "react"; +import { DropElement } from "./DropElement/DropElement"; +import { SendElement } from "./SendElement/SendElement"; +import { useBootloaderState } from "./useBootloaderState"; +import { LoadingElement } from "./LoadingElement/LoadingElement"; +import { ResponseElement } from "./ResponseElement/ResponseElement"; +import { Controls } from "./Controls/Controls"; +import { Island } from "components/Island/Island"; + +type Props = { + boards: string[]; +}; + +export const Bootloader = ({ boards }: Props) => { + const [state, upload, download, setFile, removeFile] = useBootloaderState(); + const [board, setBoard] = useState(boards[0] ?? "Default"); + + return ( + +
+ {state.kind == "empty" && } + {state.kind == "send" && ( + removeFile()} + /> + )} + {state.kind == "awaiting" && ( + + )} + {state.kind == "success" && } + {state.kind == "failure" && } + {(state.kind == "empty" || state.kind == "send") && ( + setBoard(board)} + onDownloadClick={() => download(board)} + onUploadClick={() => { + if (state.kind == "send") upload(board, state.file); + }} + /> + )} +
+
+ ); +}; diff --git a/ethernet-view/src/components/BootloaderContainer/Bootloader/BootloaderViewState.ts b/ethernet-view/src/components/BootloaderContainer/Bootloader/BootloaderViewState.ts new file mode 100644 index 000000000..2999fcfa0 --- /dev/null +++ b/ethernet-view/src/components/BootloaderContainer/Bootloader/BootloaderViewState.ts @@ -0,0 +1,28 @@ +export type BootloaderViewState = + | EmptyState + | SendState + | AwaitingState + | SuccessState + | FailureState; + +type EmptyState = { + kind: "empty"; +}; + +type SendState = { + kind: "send"; + file: File; +}; + +type AwaitingState = { + kind: "awaiting"; + progress: number; // Between 0 and 100 +}; + +type SuccessState = { + kind: "success"; +}; + +type FailureState = { + kind: "failure"; +}; diff --git a/ethernet-view/src/components/BootloaderContainer/Bootloader/Controls/Controls.module.scss b/ethernet-view/src/components/BootloaderContainer/Bootloader/Controls/Controls.module.scss new file mode 100644 index 000000000..acd09fd08 --- /dev/null +++ b/ethernet-view/src/components/BootloaderContainer/Bootloader/Controls/Controls.module.scss @@ -0,0 +1,7 @@ +.boardControlsWrapper { + width: 12rem; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0.5rem; +} diff --git a/ethernet-view/src/components/BootloaderContainer/Bootloader/Controls/Controls.tsx b/ethernet-view/src/components/BootloaderContainer/Bootloader/Controls/Controls.tsx new file mode 100644 index 000000000..dba7a1edf --- /dev/null +++ b/ethernet-view/src/components/BootloaderContainer/Bootloader/Controls/Controls.tsx @@ -0,0 +1,41 @@ +import { Dropdown } from "components/FormComponents/Dropdown/Dropdown"; +import styles from "./Controls.module.scss"; +import { Button } from "components/FormComponents/Button/Button"; + +type Props = { + options: Array; + enableUpload: boolean; + onBoardChange: (board: string) => void; + onDownloadClick: () => void; + onUploadClick: () => void; +}; + +export const Controls = ({ + options, + enableUpload, + onBoardChange, + onDownloadClick, + onUploadClick, +}: Props) => { + return ( +
+ + + + +
+ ); +}; diff --git a/ethernet-view/src/components/BootloaderContainer/Bootloader/DropElement/DropElement.module.scss b/ethernet-view/src/components/BootloaderContainer/Bootloader/DropElement/DropElement.module.scss new file mode 100644 index 000000000..0989f4dfd --- /dev/null +++ b/ethernet-view/src/components/BootloaderContainer/Bootloader/DropElement/DropElement.module.scss @@ -0,0 +1,30 @@ +@use "src/styles/styles"; +@use "src/styles/colors" as colors; + +.dropElementWrapper { + flex: 1 1 0; + display: flex; + justify-content: center; + align-items: center; + padding: 1rem; + outline: 1px dashed colors.getThemeColor("border"); + border-radius: styles.$large-border-radius; + + text-align: center; + overflow: hidden; + min-width: 6rem; +} + +.dragOver { + outline: 2px solid colors.getThemeColor("primary"); + background-color: colors.getThemeColor("primary-surface"); +} + +.link { + font-weight: bold; + color: colors.getThemeColor("primary"); + + &:hover { + color: colors.getThemeColor("primary-hover"); + } +} diff --git a/ethernet-view/src/components/BootloaderContainer/Bootloader/DropElement/DropElement.tsx b/ethernet-view/src/components/BootloaderContainer/Bootloader/DropElement/DropElement.tsx new file mode 100644 index 000000000..9c788d6af --- /dev/null +++ b/ethernet-view/src/components/BootloaderContainer/Bootloader/DropElement/DropElement.tsx @@ -0,0 +1,48 @@ +import styles from "./DropElement.module.scss"; +import { useState } from "react"; +import { FileInput } from "components/FormComponents/FileInput/FileInput"; +import { getFile } from "../getFile"; + +type Props = { + onFile: (file: File) => void; +}; + +export const DropElement = ({ onFile }: Props) => { + const [isDragOver, setDragOver] = useState(false); + + return ( +
{ + setDragOver(true); + }} + onDragLeave={(ev) => { + if (ev.currentTarget.contains(ev.relatedTarget as Node)) { + return; + } + setDragOver(false); + }} + // onDragLeaveCapture={() => setDragOver(false)} + onDrop={(ev) => { + const file = getFile(ev, "bin"); + if (file) { + onFile(file); + } + }} + onDragOver={(ev) => ev.preventDefault()} + > +
+ Drop or  + +
+
+ ); +}; diff --git a/ethernet-view/src/components/BootloaderContainer/Bootloader/LoadingElement/AnimatedEllipsis/AnimatedEllipsis.tsx b/ethernet-view/src/components/BootloaderContainer/Bootloader/LoadingElement/AnimatedEllipsis/AnimatedEllipsis.tsx new file mode 100644 index 000000000..7c86c5d5d --- /dev/null +++ b/ethernet-view/src/components/BootloaderContainer/Bootloader/LoadingElement/AnimatedEllipsis/AnimatedEllipsis.tsx @@ -0,0 +1,14 @@ +import { useInterval } from "hooks/useInterval"; +import { useState } from "react"; + +export const AnimatedEllipsis = () => { + const [points, setPoints] = useState(["."]); + + useInterval(() => { + setPoints((prevPoints) => { + return new Array((prevPoints.length + 1) % 4).fill("."); + }); + }, 800); + + return {points}; +}; diff --git a/ethernet-view/src/components/BootloaderContainer/Bootloader/LoadingElement/LoadingElement.module.scss b/ethernet-view/src/components/BootloaderContainer/Bootloader/LoadingElement/LoadingElement.module.scss new file mode 100644 index 000000000..d4400dfb5 --- /dev/null +++ b/ethernet-view/src/components/BootloaderContainer/Bootloader/LoadingElement/LoadingElement.module.scss @@ -0,0 +1,10 @@ +.awaitElementWrapper { + flex: 1 1 0; + display: flex; + flex-direction: column; + justify-content: center; + align-items: stretch; + font-size: 1.5rem; + text-align: center; + gap: 1rem; +} diff --git a/ethernet-view/src/components/BootloaderContainer/Bootloader/LoadingElement/LoadingElement.tsx b/ethernet-view/src/components/BootloaderContainer/Bootloader/LoadingElement/LoadingElement.tsx new file mode 100644 index 000000000..d814eb2ea --- /dev/null +++ b/ethernet-view/src/components/BootloaderContainer/Bootloader/LoadingElement/LoadingElement.tsx @@ -0,0 +1,15 @@ +import styles from "./LoadingElement.module.scss"; +import { ProgressBar } from "components/ProgressBar/ProgressBar"; + +type Props = { + progress: number; +}; + +export const LoadingElement = ({ progress }: Props) => { + return ( +
+ Loading + +
+ ); +}; diff --git a/ethernet-view/src/components/BootloaderContainer/Bootloader/ResponseElement/ResponseElement.module.scss b/ethernet-view/src/components/BootloaderContainer/Bootloader/ResponseElement/ResponseElement.module.scss new file mode 100644 index 000000000..313614f38 --- /dev/null +++ b/ethernet-view/src/components/BootloaderContainer/Bootloader/ResponseElement/ResponseElement.module.scss @@ -0,0 +1,22 @@ +@use "src/styles/colors" as colors; + +.responseElementWrapper { + flex: 1 1 0; + display: flex; + justify-content: center; + align-items: center; + font-size: 1.5rem; + gap: 0.5rem; +} + +.icon { + font-size: 2rem; +} + +.successIcon { + color: colors.getThemeColor("success"); +} + +.failureIcon { + color: colors.getThemeColor("error"); +} diff --git a/ethernet-view/src/components/BootloaderContainer/Bootloader/ResponseElement/ResponseElement.tsx b/ethernet-view/src/components/BootloaderContainer/Bootloader/ResponseElement/ResponseElement.tsx new file mode 100644 index 000000000..f9bfcfc2e --- /dev/null +++ b/ethernet-view/src/components/BootloaderContainer/Bootloader/ResponseElement/ResponseElement.tsx @@ -0,0 +1,30 @@ +import styles from "./ResponseElement.module.scss"; +import { HiCheckCircle } from "react-icons/hi"; +import { MdCancel } from "react-icons/md"; + +type Props = { + success: boolean; +}; + +export const ResponseElement = ({ success }: Props) => { + return ( +
+ {success && ( + <> + + Success! + + )} + {!success && ( + <> + + Error! + + )} +
+ ); +}; diff --git a/ethernet-view/src/components/BootloaderContainer/Bootloader/SendElement/SendElement.module.scss b/ethernet-view/src/components/BootloaderContainer/Bootloader/SendElement/SendElement.module.scss new file mode 100644 index 000000000..e3365be57 --- /dev/null +++ b/ethernet-view/src/components/BootloaderContainer/Bootloader/SendElement/SendElement.module.scss @@ -0,0 +1,46 @@ +@use "src/styles/colors" as colors; + +.sendElementWrapper { + flex: 1 1 0; + align-self: center; + height: fit-content; + display: flex; + flex-direction: row; + align-items: center; + gap: 1rem; + padding: 1rem 0.5rem; + border-radius: 1rem; + background-color: colors.getThemeColor("surface-variant"); +} + +.fileIcon { + font-size: 0.9rem; + color: colors.getThemeColor("secondary"); +} + +.fileData { + flex: 1 1 0; + display: flex; + flex-direction: column; + gap: 0.5rem; + text-overflow: ellipsis; + font-size: 1rem; + color: colors.getThemeColor("text-primary"); +} + +.name { + flex: 1 1 0; + font-weight: bold; + text-overflow: ellipsis; +} + +.removeBtn { + cursor: pointer; + width: 25px; + height: 25px; + color: colors.getThemeColor("text-secondary"); + + &:hover { + color: colors.getThemeColor("error"); + } +} diff --git a/ethernet-view/src/components/BootloaderContainer/Bootloader/SendElement/SendElement.tsx b/ethernet-view/src/components/BootloaderContainer/Bootloader/SendElement/SendElement.tsx new file mode 100644 index 000000000..400e87a0f --- /dev/null +++ b/ethernet-view/src/components/BootloaderContainer/Bootloader/SendElement/SendElement.tsx @@ -0,0 +1,24 @@ +import styles from "./SendElement.module.scss"; +import { ReactComponent as FileIcon } from "assets/svg/binary-file.svg"; +import { ReactComponent as Cross } from "assets/svg/cross.svg"; + +type Props = { + file: { name: string; size: number }; + onRemove: () => void; +}; + +export const SendElement = ({ file, onRemove }: Props) => { + return ( +
+ +
+
{file.name}
+
{file.size} B
+
+ onRemove()} + /> +
+ ); +}; diff --git a/ethernet-view/src/components/BootloaderContainer/Bootloader/getFile.ts b/ethernet-view/src/components/BootloaderContainer/Bootloader/getFile.ts new file mode 100644 index 000000000..158884fbd --- /dev/null +++ b/ethernet-view/src/components/BootloaderContainer/Bootloader/getFile.ts @@ -0,0 +1,57 @@ +import { DragEvent } from "react"; + +function isCorrectFormat(fileName: string, fileFormat: string): boolean { + return fileName.endsWith(`.${fileFormat}`); +} + +export function getFile( + ev: DragEvent, + format: string +): File | null { + ev.preventDefault(); + + if (ev.dataTransfer.items) { + return handleFileWithDataTransferItems(ev, format); + } else { + return handleFileWidthDataTransferFiles(ev); + } +} + +function handleFileWithDataTransferItems( + ev: DragEvent, + format: string +): File | null { + if (ev.dataTransfer.items.length > 1 || ev.dataTransfer.items.length < 1) { + console.error("Expected one file"); + return null; + } else if (ev.dataTransfer.items[0].kind !== "file") { + console.error("Dropped item is not file"); + return null; + } else if ( + !isCorrectFormat( + [...ev.dataTransfer.items][0].getAsFile()!.name, + format + ) + ) { + const fileName = [...ev.dataTransfer.items][0].getAsFile()!.name; + const extension = fileName.substring(fileName.lastIndexOf(".")); + + console.error( + `Incorrect file format: expected "${format}" got "${extension}"` + ); + return null; + } else { + return [...ev.dataTransfer.items][0].getAsFile()!; + } +} + +function handleFileWidthDataTransferFiles( + ev: DragEvent +): File | null { + if (ev.dataTransfer.files.length > 1 || ev.dataTransfer.files.length < 1) { + console.error("Expected one file"); + return null; + } else { + return [...ev.dataTransfer.files][0]; + } +} diff --git a/ethernet-view/src/components/BootloaderContainer/Bootloader/useBootloaderState.ts b/ethernet-view/src/components/BootloaderContainer/Bootloader/useBootloaderState.ts new file mode 100644 index 000000000..baaabd794 --- /dev/null +++ b/ethernet-view/src/components/BootloaderContainer/Bootloader/useBootloaderState.ts @@ -0,0 +1,61 @@ +import { BootloaderViewState } from "./BootloaderViewState"; +import { useState } from "react"; +import { useBootloader } from "services/useBootloader"; + +export function useBootloaderState() { + const [state, setState] = useState({ kind: "empty" }); + + const { uploader, downloader } = useBootloader( + onSuccess, + onFailure, + onSuccess, + onFailure, + onProgress + ); + + function upload(board: string, file: File) { + file.arrayBuffer().then((data) => { + uploader( + board, + window.btoa(Array.from(new Uint8Array(data), (x) => String.fromCodePoint(x)).join("")) + ); + }); + } + + function download(board: string) { + downloader(board); + setState({ kind: "awaiting", progress: 0 }); + } + + function onSuccess() { + setState({ kind: "success" }); + setTimeout(() => { + setState(() => { + return { kind: "empty" }; + }); + }, 1000); + } + + function onFailure() { + setState({ kind: "failure" }); + setTimeout(() => { + setState(() => { + return { kind: "empty" }; + }); + }, 1000); + } + + function onProgress(progress: number) { + setState({ kind: "awaiting", progress: progress }); + } + + function removeFile() { + setState({ kind: "empty" }); + } + + function setFile(file: File) { + setState({ kind: "send", file: file }); + } + + return [state, upload, download, setFile, removeFile] as const; +} diff --git a/ethernet-view/src/components/BootloaderContainer/BootloaderContainer.tsx b/ethernet-view/src/components/BootloaderContainer/BootloaderContainer.tsx new file mode 100644 index 000000000..7b9bbe49f --- /dev/null +++ b/ethernet-view/src/components/BootloaderContainer/BootloaderContainer.tsx @@ -0,0 +1,22 @@ +import { Bootloader } from "./Bootloader/Bootloader"; +import { useEffect, useState } from "react"; +import { config, useFetchBack } from "common"; + +export const BootloaderContainer = () => { + const [boards, setBoards] = useState(); + const uploadableBoardsPromise = useFetchBack( + import.meta.env.PROD, + config.paths.uploadableBoards + ); + useEffect(() => { + uploadableBoardsPromise.then((value: string[]) => { + setBoards(value); + }); + }, []); + + if (boards) { + return ; + } else { + return <>Fetching boards...; + } +}; diff --git a/ethernet-view/src/components/Caret/Caret.module.scss b/ethernet-view/src/components/Caret/Caret.module.scss new file mode 100644 index 000000000..e856b3c18 --- /dev/null +++ b/ethernet-view/src/components/Caret/Caret.module.scss @@ -0,0 +1,9 @@ +.caretWrapper { + width: min-content; + height: min-content; + display: flex; + justify-content: center; + align-items: center; + color: inherit; + cursor: pointer; +} diff --git a/ethernet-view/src/components/Caret/Caret.tsx b/ethernet-view/src/components/Caret/Caret.tsx new file mode 100644 index 000000000..47d9275b8 --- /dev/null +++ b/ethernet-view/src/components/Caret/Caret.tsx @@ -0,0 +1,19 @@ +import styles from "components/Caret/Caret.module.scss"; +import { BsFillCaretRightFill } from "react-icons/bs"; +type Props = { + isOpen: boolean; + onClick?: () => void; + className?: string; +}; + +export const Caret = ({ isOpen, onClick, className = "" }: Props) => { + return ( +
+ {} +
+ ); +}; diff --git a/ethernet-view/src/components/ChartMenu/ChartElement/ChartCanvas/ChartCanvas.tsx b/ethernet-view/src/components/ChartMenu/ChartElement/ChartCanvas/ChartCanvas.tsx new file mode 100644 index 000000000..323cb324e --- /dev/null +++ b/ethernet-view/src/components/ChartMenu/ChartElement/ChartCanvas/ChartCanvas.tsx @@ -0,0 +1,140 @@ +import { + MeasurementId, + NumericMeasurementInfo, + UpdateFunctionNumeric, + useGlobalTicker, +} from "common"; +import { + ColorType, + IChartApi, + ISeriesApi, + UTCTimestamp, + createChart, +} from "lightweight-charts"; +import { useEffect, useRef } from "react"; + +type DataSerieAndUpdater = Map< + MeasurementId, + [ISeriesApi<"Line">, UpdateFunctionNumeric] +>; + +interface Props { + measurementsInChart: NumericMeasurementInfo[]; +} + +const CHART_HEIGHT = 300; + +export const ChartCanvas = ({ measurementsInChart }: Props) => { + const chart = useRef(null); + const chartContainerRef = useRef(null); + const chartDataSeries = useRef(new Map()); + + // Helper function to get theme-aware chart options + const getThemeOptions = () => { + const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; + return { + layout: { + background: { + type: ColorType.Solid, + color: isDark ? 'black' : 'white' + }, + textColor: isDark ? 'white' : 'black', + }, + grid: { + vertLines: { + color: isDark ? '#1f1f1f' : '#f0f0f0', + }, + horzLines: { + color: isDark ? '#1f1f1f' : '#f0f0f0', + }, + }, + }; + }; + + useEffect(() => { + const handleResize = () => { + if (chartContainerRef.current && chart.current) { + chart.current.applyOptions({ + width: chartContainerRef.current.clientWidth, + }); + } + }; + + const resizeObserver = new ResizeObserver(handleResize); + let themeObserver: MutationObserver | null = null; + + if (chartContainerRef.current) { + resizeObserver.observe(chartContainerRef.current); + + chart.current = createChart(chartContainerRef.current, { + ...getThemeOptions(), + width: chartContainerRef.current.clientWidth, + height: CHART_HEIGHT, + timeScale: { + timeVisible: true, + secondsVisible: true, + rightOffset: 12, + barSpacing: 0.1, + tickMarkFormatter: (time: UTCTimestamp) => { + const date = new Date(time * 1000); + return date.toLocaleTimeString() + "." + date.getMilliseconds(); + }, + }, + localization: { + timeFormatter: (time: UTCTimestamp) => { + const date = new Date(time * 1000); + return date.toLocaleTimeString() + "." + date.getMilliseconds(); + }, + }, + }); + + // Set up MutationObserver to watch for theme changes + themeObserver = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'attributes' && mutation.attributeName === 'data-theme') { + chart.current?.applyOptions(getThemeOptions()); + } + }); + }); + + themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme'] + }); + } + + return () => { + resizeObserver.disconnect(); + themeObserver?.disconnect(); + chart.current?.remove(); + }; + }, []); + + useEffect(() => { + chartDataSeries.current.clear(); + measurementsInChart.forEach((measurement) => { + if (chart.current) + chartDataSeries.current.set(measurement.id, [ + chart.current.addLineSeries({ + color: measurement.color, + priceFormat: { + type: "price", + precision: 3, + minMove: 0.001, + }, + }), + measurement.getUpdate, + ]); + }); + }); + + useGlobalTicker(() => { + const now = (Date.now() / 1000) as UTCTimestamp; + chartDataSeries.current?.forEach((serieAndUpdater) => { + const [DataSerie, Updater] = serieAndUpdater; + DataSerie.update({ time: now, value: Updater() }); + }); + }); + + return
; +}; diff --git a/ethernet-view/src/components/ChartMenu/ChartElement/ChartElement.module.scss b/ethernet-view/src/components/ChartMenu/ChartElement/ChartElement.module.scss new file mode 100644 index 000000000..4b556f5af --- /dev/null +++ b/ethernet-view/src/components/ChartMenu/ChartElement/ChartElement.module.scss @@ -0,0 +1,15 @@ +@use "src/styles/styles"; +@use "src/styles/colors" as colors; + +.chartWrapper { + flex-shrink: 0; + + border: 1px solid colors.getThemeColor("border"); + border-radius: styles.$normal-border-radius; + overflow: hidden; + background-color: colors.getThemeColor("surface"); +} + +.chart { + padding: 0.8rem; +} diff --git a/ethernet-view/src/components/ChartMenu/ChartElement/ChartElement.tsx b/ethernet-view/src/components/ChartMenu/ChartElement/ChartElement.tsx new file mode 100644 index 000000000..39c2e3db7 --- /dev/null +++ b/ethernet-view/src/components/ChartMenu/ChartElement/ChartElement.tsx @@ -0,0 +1,65 @@ +import styles from "./ChartElement.module.scss"; +import { AiOutlineCloseCircle } from 'react-icons/ai' +import { MeasurementId, NumericMeasurementInfo, useMeasurementsStore } from 'common'; +import { ChartCanvas } from './ChartCanvas/ChartCanvas'; +import { ChartLegend } from './ChartLegend/ChartLegend'; +import { memo, useCallback, useState } from "react"; +import { ChartId } from "../ChartMenu"; + +type Props = { + chartId: ChartId; + initialMeasurementId: MeasurementId; + removeChart: (chartId: ChartId) => void; +}; + +// React component that keeps the chart render and measurements represented on it. +export const ChartElement = memo(({ chartId, initialMeasurementId, removeChart }: Props) => { + + const getNumericMeasurementInfo = useMeasurementsStore(state => state.getNumericMeasurementInfo); + const initialMeasurement = getNumericMeasurementInfo(initialMeasurementId); + + const [measurementsInChart, setMeasurementsInChart] = useState([initialMeasurement]); + + const addMeasurementToChart = (measurement: NumericMeasurementInfo) => { + if(!measurementsInChart.some(measurementInChart => measurementInChart.id === measurement.id)) { + setMeasurementsInChart([...measurementsInChart, measurement]); + } + } + + const removeMeasurementFromChart = useCallback((measurementId: MeasurementId) => { + setMeasurementsInChart(prevMeasurements => prevMeasurements.filter(measurement => measurement.id !== measurementId)); + }, []); + + const handleDrop = (ev: React.DragEvent) => { + ev.stopPropagation(); + const id = ev.dataTransfer.getData("id"); + const measurementInfo = getNumericMeasurementInfo(id); + addMeasurementToChart(measurementInfo); + }; + + return ( +
ev.preventDefault()} + onDragOver={(ev) => ev.preventDefault()} + > +
+ removeChart(chartId)} + /> + + +
+
+ ); +}); diff --git a/ethernet-view/src/components/ChartMenu/ChartElement/ChartLegend/ChartLegend.module.scss b/ethernet-view/src/components/ChartMenu/ChartElement/ChartLegend/ChartLegend.module.scss new file mode 100644 index 000000000..a1bcc5880 --- /dev/null +++ b/ethernet-view/src/components/ChartMenu/ChartElement/ChartLegend/ChartLegend.module.scss @@ -0,0 +1,49 @@ +@use "src/styles/colors" as colors; + +.chartLegend { + display: flex; + gap: 1rem; + flex-wrap: wrap; + align-items: flex-start; + justify-content: flex-start; + padding: 0.8rem; +} + +:global([data-theme="dark"]) .chartLegend { + background-color: colors.getThemeColor("surface-variant"); + border-radius: 0.5rem; +} + +.chartLegendItem { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + opacity: 0.8; + } +} + +:global([data-theme="dark"]) .chartLegendItem { + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + background-color: colors.getThemeColor("surface"); + color: colors.getThemeColor("text-primary"); + + &:hover { + background-color: colors.getThemeColor("surface-hover"); + transform: translateY(-1px); + box-shadow: colors.getThemeColor("shadow-sm"); + } +} + +.chartLegendItemColor { + width: 1rem; + height: 1rem; +} + +:global([data-theme="dark"]) .chartLegendItemColor { + border-radius: 0.125rem; +} \ No newline at end of file diff --git a/ethernet-view/src/components/ChartMenu/ChartElement/ChartLegend/ChartLegend.tsx b/ethernet-view/src/components/ChartMenu/ChartElement/ChartLegend/ChartLegend.tsx new file mode 100644 index 000000000..3b2f5f7af --- /dev/null +++ b/ethernet-view/src/components/ChartMenu/ChartElement/ChartLegend/ChartLegend.tsx @@ -0,0 +1,54 @@ +import styles from "./ChartLegend.module.scss"; +import { useEffect, useRef } from "react"; +import { MeasurementId, NumericMeasurementInfo } from "common"; +import { ChartId } from "components/ChartMenu/ChartMenu"; + + +interface Props { + chartId: ChartId; + measurementsInChart: NumericMeasurementInfo[]; + removeMeasurementFromChart: (measurementId: MeasurementId) => void; + removeChart: (chartId: ChartId) => void; +} + +export const ChartLegend = ({ chartId, measurementsInChart, removeMeasurementFromChart, removeChart }: Props) => { + + const legendRef = useRef(null); + + const onRemoveMeasurement = (measurementId: MeasurementId) => { + removeMeasurementFromChart(measurementId); + }; + + useEffect(() => { + if(measurementsInChart.length == 0) removeChart(chartId); + }, [measurementsInChart.length]) + + useEffect(() => { + if (legendRef.current) { + while (legendRef.current.firstChild) { + legendRef.current.removeChild(legendRef.current.firstChild); + } + measurementsInChart.forEach((measurement) => { + const newChartLegendItem = createChartLegendItem(measurement); + newChartLegendItem.onclick = (_) => onRemoveMeasurement(measurement.id); + legendRef.current?.appendChild(newChartLegendItem); + }); + } + }); + + return
; +}; + +function createChartLegendItem(measurement: NumericMeasurementInfo) { + const legendItem = document.createElement("div"); + legendItem.setAttribute("data-id", measurement.id); + legendItem.className = styles.chartLegendItem; + const seriesColor = document.createElement("div"); + seriesColor.className = styles.chartLegendItemColor; + seriesColor.style.backgroundColor = measurement.color; + const seriesName = document.createElement("p"); + seriesName.innerText = measurement.name; + legendItem.appendChild(seriesColor); + legendItem.appendChild(seriesName); + return legendItem; +} diff --git a/ethernet-view/src/components/ChartMenu/ChartMenu.module.scss b/ethernet-view/src/components/ChartMenu/ChartMenu.module.scss new file mode 100644 index 000000000..edfb04f94 --- /dev/null +++ b/ethernet-view/src/components/ChartMenu/ChartMenu.module.scss @@ -0,0 +1,72 @@ +@use "src/styles/styles"; +@use "src/styles/colors" as colors; + +.chartMenuWrapper { + width: 100%; + height: 100%; + display: grid; + grid-template-columns: minmax(12rem, auto) 1fr; + transition: grid-template-columns 0.3s ease; + + border-radius: styles.$normal-border-radius; + overflow: hidden; + + border: 1px solid colors.getThemeColor("primary"); + border-left: 0; +} + +:global([data-theme="dark"]) .chartMenuWrapper { + border-color: colors.getColor("neutral", 20); +} + +.chartMenuWrapper.sidebarHidden { + grid-template-columns: 1fr; +} + +.chartContentWrapper { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; +} + +.toggleButton { + position: absolute; + top: 0.5rem; + left: 0.5rem; + z-index: 1000; + background-color: colors.getThemeColor("surface"); + border: 1px solid colors.getThemeColor("border"); + color: colors.getThemeColor("text-primary"); + border-radius: 4px; + padding: 0.25rem 0.5rem; + cursor: pointer; + font-size: 0.8rem; + transition: background-color 0.2s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + + &:hover { + background-color: colors.getThemeColor("primary-surface"); + } +} + +.noValues { + text-align: center; + color: colors.getThemeColor("text-tertiary"); +} + +.chartListWrapper { + display: flex; + flex-direction: column; + gap: 1rem; + + width: 100%; + height: 100%; + background-color: colors.getThemeColor("surface"); + padding: 1rem; + overflow-y: auto; + overflow-x: hidden; +} diff --git a/ethernet-view/src/components/ChartMenu/ChartMenu.tsx b/ethernet-view/src/components/ChartMenu/ChartMenu.tsx new file mode 100644 index 000000000..692a1ddaa --- /dev/null +++ b/ethernet-view/src/components/ChartMenu/ChartMenu.tsx @@ -0,0 +1,95 @@ +import styles from "components/ChartMenu/ChartMenu.module.scss"; +import { DragEvent, memo, useCallback, useState, useEffect } from "react"; +import Sidebar from "components/ChartMenu/Sidebar/Sidebar"; +import { Section } from "./Sidebar/Section/Section"; +import { MeasurementId, useMeasurementsStore } from "common"; +import { nanoid } from "nanoid"; +import { ChartElement } from "./ChartElement/ChartElement"; + +export type ChartId = string; + +export type ChartInfo = { + chartId: ChartId; + initialMeasurementId: MeasurementId; +}; + +type Props = { + sidebarSections: Section[]; +}; + +export const ChartMenu = memo(({ sidebarSections }: Props) => { + + const getNumericMeasurementInfo = useMeasurementsStore((state) => state.getNumericMeasurementInfo); + + const [charts, setCharts] = useState([]); + const [sidebarVisible, setSidebarVisible] = useState(true); + + const addChart = ((chartId: ChartId, initialMeasurementId: MeasurementId) => { + setCharts([...charts, { chartId, initialMeasurementId }]); + }); + + const removeChart = useCallback((chartId: ChartId) => { + setCharts(prevCharts => prevCharts.filter(chart => chart.chartId !== chartId)); + }, []); + + const handleDrop = (ev: DragEvent) => { + ev.preventDefault(); + const id = ev.dataTransfer.getData("id"); + const initialMeasurementId = getNumericMeasurementInfo(id).id; + addChart(nanoid(), initialMeasurementId); + }; + + const toggleSidebar = () => { + setSidebarVisible(!sidebarVisible); + }; + + // Trigger resize event when sidebar visibility changes to help charts adjust + useEffect(() => { + const timeoutId = setTimeout(() => { + window.dispatchEvent(new Event('resize')); + }, 350); // Wait for CSS transition to complete + + return () => clearTimeout(timeoutId); + }, [sidebarVisible]); + + if (sidebarSections.length == 0) { + return ( +
+ No available values to chart. This might happen if none of the + measurements are numeric (only numeric measurements are + chartable). +
+ ); + } else { + return ( +
+ {sidebarVisible && } +
+ +
ev.preventDefault()} + onDragOver={(ev) => ev.preventDefault()} + > + {charts.map((chart) => ( + + ))} +
+
+
+ ); + } +}); + diff --git a/ethernet-view/src/components/ChartMenu/Sidebar/Section/Section.module.scss b/ethernet-view/src/components/ChartMenu/Sidebar/Section/Section.module.scss new file mode 100644 index 000000000..d953a5c81 --- /dev/null +++ b/ethernet-view/src/components/ChartMenu/Sidebar/Section/Section.module.scss @@ -0,0 +1,36 @@ +@use "src/styles/styles"; +@use "src/styles/colors" as colors; + +.section { + display: flex; + flex-direction: column; + + cursor: pointer; +} + +.header { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.4rem; + padding: 0.5rem; + background-color: colors.getColor("primary", 80); + color: colors.getColor("primary", 50); + font-weight: bold; + border-bottom: 1px solid colors.getColor("primary", 70); + transition: all 0.2s ease; + + &:hover { + background-color: colors.getColor("primary", 75); + } +} + +:global([data-theme="dark"]) .header { + background-color: colors.getThemeColor("surface-variant"); + color: colors.getThemeColor("text-primary"); + border-bottom: 1px solid colors.getThemeColor("border"); + + &:hover { + background-color: colors.getThemeColor("surface-hover"); + } +} diff --git a/ethernet-view/src/components/ChartMenu/Sidebar/Section/Section.tsx b/ethernet-view/src/components/ChartMenu/Sidebar/Section/Section.tsx new file mode 100644 index 000000000..1f700c9dc --- /dev/null +++ b/ethernet-view/src/components/ChartMenu/Sidebar/Section/Section.tsx @@ -0,0 +1,37 @@ +import styles from "./Section.module.scss"; +import { Caret } from "components/Caret/Caret"; +import { useState } from "react"; +import { SubsectionsView } from "./Subsection/Subsections"; +import { Subsection } from "./Subsection/Subsection/Subsection"; + +export type Section = { + name: string; + subsections: Subsection[]; +}; + +type Props = { + section: Section; +}; + +export const Section = ({ section }: Props) => { + const [isOpen, setIsOpen] = useState(false); + return ( +
+
{ + setIsOpen((prev) => !prev); + }} + > + + {section.name} +
+ {isOpen && ( + + )} +
+ ); +}; diff --git a/ethernet-view/src/components/ChartMenu/Sidebar/Section/Subsection/Subsection/Items/Item/ItemView.module.scss b/ethernet-view/src/components/ChartMenu/Sidebar/Section/Subsection/Subsection/Items/Item/ItemView.module.scss new file mode 100644 index 000000000..24df18c4e --- /dev/null +++ b/ethernet-view/src/components/ChartMenu/Sidebar/Section/Subsection/Subsection/Items/Item/ItemView.module.scss @@ -0,0 +1,10 @@ +@use "src/styles/styles"; + +.item { + display: grid; + align-items: center; + grid-template-columns: auto 1fr; + column-gap: 0.3rem; + font-weight: 500; + cursor: pointer; +} diff --git a/ethernet-view/src/components/ChartMenu/Sidebar/Section/Subsection/Subsection/Items/Item/ItemView.tsx b/ethernet-view/src/components/ChartMenu/Sidebar/Section/Subsection/Subsection/Items/Item/ItemView.tsx new file mode 100644 index 000000000..3953bb790 --- /dev/null +++ b/ethernet-view/src/components/ChartMenu/Sidebar/Section/Subsection/Subsection/Items/Item/ItemView.tsx @@ -0,0 +1,28 @@ +import styles from "./ItemView.module.scss"; +import { DragEvent } from "react"; +import { FiBox } from "react-icons/fi"; + +export type Item = { + id: string; + name: string; +}; + +type Props = { + item: Item; +}; + +export const ItemView = ({ item }: Props) => { + function handleDragStart(ev: DragEvent) { + ev.dataTransfer.setData("id", item.id); + } + + return ( +
+ {item.name} +
+ ); +}; diff --git a/ethernet-view/src/components/ChartMenu/Sidebar/Section/Subsection/Subsection/Items/Items.module.scss b/ethernet-view/src/components/ChartMenu/Sidebar/Section/Subsection/Subsection/Items/Items.module.scss new file mode 100644 index 000000000..0d8711e99 --- /dev/null +++ b/ethernet-view/src/components/ChartMenu/Sidebar/Section/Subsection/Subsection/Items/Items.module.scss @@ -0,0 +1,6 @@ +.items { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-left: 1rem; +} diff --git a/ethernet-view/src/components/ChartMenu/Sidebar/Section/Subsection/Subsection/Items/Items.tsx b/ethernet-view/src/components/ChartMenu/Sidebar/Section/Subsection/Subsection/Items/Items.tsx new file mode 100644 index 000000000..0a6ba7a84 --- /dev/null +++ b/ethernet-view/src/components/ChartMenu/Sidebar/Section/Subsection/Subsection/Items/Items.tsx @@ -0,0 +1,21 @@ +import { ItemView, Item } from "./Item/ItemView"; +import styles from "./Items.module.scss"; + +type Props = { + items: Item[]; +}; + +export const Items = ({ items }: Props) => { + return ( +
+ {items.map((item) => { + return ( + + ); + })} +
+ ); +}; diff --git a/ethernet-view/src/components/ChartMenu/Sidebar/Section/Subsection/Subsection/Subsection.module.scss b/ethernet-view/src/components/ChartMenu/Sidebar/Section/Subsection/Subsection/Subsection.module.scss new file mode 100644 index 000000000..60c8be652 --- /dev/null +++ b/ethernet-view/src/components/ChartMenu/Sidebar/Section/Subsection/Subsection/Subsection.module.scss @@ -0,0 +1,13 @@ +@use "src/styles/styles"; +@use "src/styles/colors" as colors; + +.subsection { + display: flex; + flex-direction: column; +} + +.name { + margin-bottom: 0.5rem; + font-style: italic; + color: colors.getThemeColor("text-tertiary"); +} diff --git a/ethernet-view/src/components/ChartMenu/Sidebar/Section/Subsection/Subsection/Subsection.tsx b/ethernet-view/src/components/ChartMenu/Sidebar/Section/Subsection/Subsection/Subsection.tsx new file mode 100644 index 000000000..dba1b75da --- /dev/null +++ b/ethernet-view/src/components/ChartMenu/Sidebar/Section/Subsection/Subsection/Subsection.tsx @@ -0,0 +1,21 @@ +import styles from "./Subsection.module.scss"; +import { Items } from "./Items/Items"; +import { Item } from "./Items/Item/ItemView"; + +export type Subsection = { + name: string; + items: Item[]; +}; + +type Props = { + subsection: Subsection; +}; + +export const SubsectionView = ({ subsection }: Props) => { + return ( +
+
{subsection.name}
+ +
+ ); +}; diff --git a/ethernet-view/src/components/ChartMenu/Sidebar/Section/Subsection/Subsections.module.scss b/ethernet-view/src/components/ChartMenu/Sidebar/Section/Subsection/Subsections.module.scss new file mode 100644 index 000000000..a17d0de74 --- /dev/null +++ b/ethernet-view/src/components/ChartMenu/Sidebar/Section/Subsection/Subsections.module.scss @@ -0,0 +1,12 @@ +.subsections { + display: flex; + flex-direction: column; + padding-top: 0.5rem; + padding-left: 1rem; + padding-right: 1.5rem; + overflow: hidden; + + > * { + margin-bottom: 0.8rem; + } +} diff --git a/ethernet-view/src/components/ChartMenu/Sidebar/Section/Subsection/Subsections.tsx b/ethernet-view/src/components/ChartMenu/Sidebar/Section/Subsection/Subsections.tsx new file mode 100644 index 000000000..6bbcd7b74 --- /dev/null +++ b/ethernet-view/src/components/ChartMenu/Sidebar/Section/Subsection/Subsections.tsx @@ -0,0 +1,25 @@ +import styles from "./Subsections.module.scss"; +import { Subsection, SubsectionView } from "./Subsection/Subsection"; + +type Props = { + subsections: Subsection[]; + isVisible: boolean; +}; + +export const SubsectionsView = ({ subsections, isVisible }: Props) => { + return ( +
+ {subsections.map((subsection) => { + return ( + + ); + })} +
+ ); +}; diff --git a/ethernet-view/src/components/ChartMenu/Sidebar/Sidebar.module.scss b/ethernet-view/src/components/ChartMenu/Sidebar/Sidebar.module.scss new file mode 100644 index 000000000..c12d35c6c --- /dev/null +++ b/ethernet-view/src/components/ChartMenu/Sidebar/Sidebar.module.scss @@ -0,0 +1,17 @@ +@use "src/styles/styles"; +@use "src/styles/colors" as colors; + +.sidebar { + width: auto; + height: 100%; + + display: flex; + flex-direction: column; + overflow: auto; + + background-color: colors.getThemeColor("primary-surface"); +} + +:global([data-theme="dark"]) .sidebar { + background-color: colors.getColor("neutral", 20); +} diff --git a/ethernet-view/src/components/ChartMenu/Sidebar/Sidebar.tsx b/ethernet-view/src/components/ChartMenu/Sidebar/Sidebar.tsx new file mode 100644 index 000000000..dccc98d3d --- /dev/null +++ b/ethernet-view/src/components/ChartMenu/Sidebar/Sidebar.tsx @@ -0,0 +1,43 @@ +import styles from "./Sidebar.module.scss"; +import { Section } from "./Section/Section"; +import { memo } from "react"; +import { Item, ItemView } from "./Section/Subsection/Subsection/Items/Item/ItemView"; + +type Props = { + sections?: Section[]; + items?: Item[]; +}; + +const Sidebar = ({ sections, items }: Props) => { + + return sections && sections.length > 0 ? + ( +
+ {sections.map((section) => { + return ( +
+ ); + })} +
+ ) : (items && items.length > 0 ? ( +
+ {items.map((item) => { + return ( + //
+ //
{item.name}
+ //
+ + ); + })} +
+ ) : null); + +}; + +export default memo(Sidebar); diff --git a/ethernet-view/src/components/ChartMenu/sidebar.ts b/ethernet-view/src/components/ChartMenu/sidebar.ts new file mode 100644 index 000000000..6a07538e2 --- /dev/null +++ b/ethernet-view/src/components/ChartMenu/sidebar.ts @@ -0,0 +1,44 @@ +import { Board, PodData, isNumericMeasurement } from "common"; +import { Packet } from "common"; +import { Section } from "./Sidebar/Section/Section"; +import { Subsection } from "./Sidebar/Section/Subsection/Subsection/Subsection"; +import { Item } from "./Sidebar/Section/Subsection/Subsection/Items/Item/ItemView"; + +export function createSidebarSections(podData: PodData): Section[] { + const sections: Section[] = []; + + podData.boards.forEach((board) => { + const subsections = getNumericPacketSubsections(board); + if (subsections.length > 0) { + sections.push({ name: board.name, subsections }); + } + }); + + return sections; +} + +function getNumericPacketSubsections(board: Board): Subsection[] { + const packets: Subsection[] = []; + + board.packets.forEach((packet) => { + const items = getNumericMeasurementItems(packet); + if (items.length > 0) { + packets.push({ name: packet.name, items }); + } + }); + + return packets; +} + +function getNumericMeasurementItems(packet: Packet): Item[] { + const items: Item[] = []; + packet.measurements.forEach((measurement) => { + if (isNumericMeasurement(measurement)) { + items.push({ + id: measurement.id, + name: measurement.name, + }); + } + }); + return items; +} diff --git a/ethernet-view/src/components/ConfigPopup/ConfigPopup.module.scss b/ethernet-view/src/components/ConfigPopup/ConfigPopup.module.scss new file mode 100644 index 000000000..4fd18ec37 --- /dev/null +++ b/ethernet-view/src/components/ConfigPopup/ConfigPopup.module.scss @@ -0,0 +1,243 @@ +@use "src/styles/styles"; +@use "src/styles/colors" as colors; + +.loadingOverlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(255, 255, 255, 0.8); + border-radius: styles.$normal-border-radius; + display: flex; + align-items: center; + justify-content: center; + z-index: 20; + backdrop-filter: blur(2px); + + &::after { + content: ""; + width: 40px; + height: 40px; + border: 4px solid colors.getThemeColor("border"); + border-top-color: colors.getThemeColor("primary"); + border-radius: 50%; + animation: spin 0.8s linear infinite; + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.popup { + position: relative; + background-color: colors.getThemeColor("surface"); + border-radius: styles.$normal-border-radius; + width: 90%; + max-width: 1000px; + max-height: 90vh; + display: flex; + flex-direction: column; + @include styles.shadow; + box-shadow: colors.getThemeColor("shadow-lg"); +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding: styles.$normal-padding; + border-bottom: styles.$normal-border-width solid + colors.getThemeColor("border"); +} + +.headerActions { + display: flex; + align-items: center; + gap: 1rem; +} + +.importButton { + flex: 0 0 auto; + min-width: 100px; +} + +.title { + @include styles.title-text; + font-size: 1.5rem; + margin: 0; +} + +.closeButton { + background: none; + border: none; + font-size: 2rem; + line-height: 1; + color: colors.getThemeColor("text-secondary"); + cursor: pointer; + padding: 0; + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: styles.$normal-border-radius; + transition: styles.$background-color-transition; + + &:hover { + background-color: colors.getThemeColor("surface-hover"); + color: colors.getThemeColor("text-primary"); + } +} + +.content { + padding: styles.$normal-padding; + overflow-y: auto; + flex: 1; +} + +.field { + margin-bottom: 1.5rem; + + &:last-child { + margin-bottom: 0; + } +} + +.label { + @include styles.normal-text; + display: block; + margin-bottom: 0.5rem; + color: colors.getThemeColor("text-primary"); +} + +.input { + width: 100%; + padding: 0.75rem; + border: styles.$normal-border-width solid colors.getThemeColor("border"); + border-radius: styles.$normal-border-radius; + background-color: colors.getThemeColor("input-bg"); + color: colors.getThemeColor("text-primary"); + font-family: styles.$sans-font; + font-size: styles.$normal-font-size; + transition: styles.$background-color-transition, + border-color styles.$normal-transition-time linear; + + &:focus { + outline: none; + border-color: colors.getThemeColor("primary"); + background-color: colors.getThemeColor("surface"); + } + + &:hover { + border-color: colors.getThemeColor("border-hover"); + } +} + +.footer { + display: flex; + gap: 1rem; + padding: styles.$normal-padding; + border-top: styles.$normal-border-width solid colors.getThemeColor("border"); + justify-content: flex-end; +} + +.cancelButton { + flex: 0 0 auto; + min-width: 100px; +} + +.saveButton { + flex: 0 0 auto; + min-width: 100px; +} + +.section { + margin-bottom: 2rem; + padding-bottom: 1.5rem; + border-bottom: styles.$normal-border-width solid + colors.getThemeColor("border"); + + &:last-of-type { + border-bottom: none; + margin-bottom: 0; + } +} + +.sectionTitle { + @include styles.normal-text; + font-size: 1.1rem; + font-weight: styles.$bold-font-weight; + color: colors.getThemeColor("text-primary"); + margin: 0 0 1rem 0; +} + +.checkboxGroup { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +.checkboxLabel { + @include styles.normal-text; + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + color: colors.getThemeColor("text-primary"); +} + +.folderPickerContainer { + display: flex; + gap: 0.5rem; + align-items: stretch; +} + +.folderInput { + flex: 1; +} + +.browseButton { + flex: 0 0 auto; + min-width: 80px; +} + +.select { + width: 100%; + padding: 0.75rem; + border: styles.$normal-border-width solid colors.getThemeColor("border"); + border-radius: styles.$normal-border-radius; + background-color: colors.getThemeColor("input-bg"); + color: colors.getThemeColor("text-primary"); + font-family: styles.$sans-font; + font-size: styles.$normal-font-size; + cursor: pointer; + transition: styles.$background-color-transition, + border-color styles.$normal-transition-time linear; + + &:focus { + outline: none; + border-color: colors.getThemeColor("primary"); + background-color: colors.getThemeColor("surface"); + } + + &:hover { + border-color: colors.getThemeColor("border-hover"); + } +} diff --git a/ethernet-view/src/components/ConfigPopup/ConfigPopup.tsx b/ethernet-view/src/components/ConfigPopup/ConfigPopup.tsx new file mode 100644 index 000000000..7c57b3e1c --- /dev/null +++ b/ethernet-view/src/components/ConfigPopup/ConfigPopup.tsx @@ -0,0 +1,651 @@ +import { useEffect, useState } from "react"; +import styles from "./ConfigPopup.module.scss"; +import { Button } from "../FormComponents/Button/Button"; +import { CheckBox } from "../FormComponents/CheckBox/CheckBox"; +import { TextInput } from "../FormComponents/TextInput/TextInput"; +import { NumericInput } from "../FormComponents/NumericInput/NumericInput"; +import type { ConfigData } from "../../types/ConfigData"; + +type Props = { + isOpen: boolean; + onClose: () => void; +}; + +const AVAILABLE_BOARDS = [ + "BCU", + "BMSL", + "HVSCU", + "HVSCU-Cabinet", + "LCU", + "PCU", + "VCU", + "BLCU", +]; + +const TIME_UNITS = ["ns", "us", "ms", "s"]; + +const DEFAULT_CONFIG: ConfigData = { + vehicle: { + boards: [ + "BCU", + "BMSL", + "HVSCU", + "HVSCU-Cabinet", + "LCU", + "PCU", + "VCU", + "BLCU", + ], + }, + adj: { + branch: "main", + }, + network: { + manual: false, + }, + transport: { + propagate_fault: false, + }, + tcp: { + backoff_min_ms: 100, + backoff_max_ms: 5000, + backoff_multiplier: 1.5, + max_retries: 0, + connection_timeout_ms: 1000, + keep_alive_ms: 1000, + }, + blcu: { + ip: "127.0.0.1", + download_order_id: 0, + upload_order_id: 0, + }, + tftp: { + block_size: 131072, + retries: 3, + timeout_ms: 5000, + backoff_factor: 2, + enable_progress: true, + }, + logging: { + time_unit: "us", + logging_path: "", + }, +}; + +export const ConfigPopup = ({ isOpen, onClose }: Props) => { + const [config, setConfig] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingConfig, setIsLoadingConfig] = useState(false); + const [isImporting, setIsImporting] = useState(false); + + // Combined disabled state for all interactions + const isDisabled = isLoading || isLoadingConfig || isImporting; + + const handleImport = async () => { + if (!window.electronAPI) { + console.warn("Electron API not available"); + return; + } + + setIsImporting(true); + try { + await window.electronAPI.importConfig(); + // Reload config after import + await loadConfig(); + } catch (error) { + console.error("Error importing config:", error); + // You might want to show an error message to the user here + } finally { + setIsImporting(false); + } + }; + + const loadConfig = async () => { + if (window.electronAPI) { + setIsLoadingConfig(true); + try { + const loadedConfig = await window.electronAPI.getConfig(); + setConfig(loadedConfig); + } catch (error) { + console.error("Error loading config:", error); + } finally { + setIsLoadingConfig(false); + } + } else { + console.log( + "Electron API is not available. Using default config constant." + ); + // No Electron API - use default config immediately + setConfig(DEFAULT_CONFIG); + } + }; + + const handleSaveConfig = async (config: ConfigData) => { + console.log("Saving config:", config); + + if (window.electronAPI) { + // Call Electron API when available + await window.electronAPI.saveConfig(config); + } else { + // Simulate config save for now + console.log("Electron API is not available. This is a browser test."); + } + }; + + // Helper function to safely update config + const updateConfig = (updater: (prev: ConfigData) => ConfigData) => { + setConfig((prev) => { + if (!prev) return null; + return updater(prev); + }); + }; + + // Load config when popup opens + useEffect(() => { + if (isOpen) { + loadConfig(); + } else { + // Reset config when popup closes + setConfig(null); + } + }, [isOpen]); + + if (!isOpen) return null; + + const handleSave = async () => { + if (!config) return; + + setIsLoading(true); + + try { + await handleSaveConfig(config); + onClose(); + } catch (error) { + console.error("Error saving config:", error); + } finally { + setIsLoading(false); + } + }; + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget && !isDisabled) { + onClose(); + } + }; + + const toggleBoard = (board: string) => { + if (!config) return; + + updateConfig((prev) => ({ + ...prev, + vehicle: { + boards: prev.vehicle.boards.includes(board) + ? prev.vehicle.boards.filter((b: string) => b !== board) + : [...prev.vehicle.boards, board], + }, + })); + }; + + return ( +
+
+ {isLoading &&
} + +
+

Configuration

+
+ +
+
+ + {isLoadingConfig ? ( +
+
+

Loading configuration...

+
+ ) : config || config != null ? ( +
+ {/* Vehicle Configuration */} +
+

Vehicle Configuration

+
+ +
+ {AVAILABLE_BOARDS.map((board) => ( + + ))} +
+
+
+ + {/* ADJ Configuration */} +
+

ADJ Configuration

+
+ + + updateConfig((prev) => ({ + ...prev, + adj: { ...prev.adj, branch: e.target.value }, + })) + } + placeholder="main" + disabled={isDisabled} + /> +
+
+ + {/* Network Configuration */} +
+

Network Configuration

+
+ +
+
+ + {/* Transport Configuration */} +
+

Transport Configuration

+
+ +
+
+ + {/* TCP Configuration */} +
+

TCP Configuration

+
+ + + updateConfig((prev) => ({ + ...prev, + tcp: { ...prev.tcp, backoff_min_ms: Number(value) || 0 }, + })) + } + /> +
+
+ + + updateConfig((prev) => ({ + ...prev, + tcp: { ...prev.tcp, backoff_max_ms: Number(value) || 0 }, + })) + } + /> +
+
+ + + updateConfig((prev) => ({ + ...prev, + tcp: { + ...prev.tcp, + backoff_multiplier: Number(value) || 0, + }, + })) + } + /> +
+
+ + + updateConfig((prev) => ({ + ...prev, + tcp: { ...prev.tcp, max_retries: Number(value) || 0 }, + })) + } + /> +
+
+ + + updateConfig((prev) => ({ + ...prev, + tcp: { + ...prev.tcp, + connection_timeout_ms: Number(value) || 0, + }, + })) + } + /> +
+
+ + + updateConfig((prev) => ({ + ...prev, + tcp: { ...prev.tcp, keep_alive_ms: Number(value) || 0 }, + })) + } + /> +
+
+ + {/* BLCU Configuration */} +
+

BLCU Configuration

+
+ + + updateConfig((prev) => ({ + ...prev, + blcu: { ...prev.blcu, ip: e.target.value }, + })) + } + placeholder="127.0.0.1" + disabled={isDisabled} + /> +
+
+ + + updateConfig((prev) => ({ + ...prev, + blcu: { + ...prev.blcu, + download_order_id: Number(value) || 0, + }, + })) + } + /> +
+
+ + + updateConfig((prev) => ({ + ...prev, + blcu: { + ...prev.blcu, + upload_order_id: Number(value) || 0, + }, + })) + } + /> +
+
+ + {/* TFTP Configuration */} +
+

TFTP Configuration

+
+ + + updateConfig((prev) => ({ + ...prev, + tftp: { ...prev.tftp, block_size: Number(value) || 0 }, + })) + } + /> +
+
+ + + updateConfig((prev) => ({ + ...prev, + tftp: { ...prev.tftp, retries: Number(value) || 0 }, + })) + } + /> +
+
+ + + updateConfig((prev) => ({ + ...prev, + tftp: { ...prev.tftp, timeout_ms: Number(value) || 0 }, + })) + } + /> +
+
+ + + updateConfig((prev) => ({ + ...prev, + tftp: { + ...prev.tftp, + backoff_factor: Number(value) || 0, + }, + })) + } + /> +
+
+ +
+
+ + {/* Logging Configuration */} +
+

Logger Configuration

+
+ + +
+
+ +
+ + updateConfig((prev) => ({ + ...prev, + logging: { + ...prev.logging, + logging_path: e.target.value, + }, + })) + } + placeholder="Select logging folder..." + disabled={isDisabled} + className={styles.folderInput} + /> +
+
+
+
+ ) : ( +

Config not found

+ )} + +
+
+
+
+ ); +}; diff --git a/ethernet-view/src/components/FormComponents/Button/Button.module.scss b/ethernet-view/src/components/FormComponents/Button/Button.module.scss new file mode 100644 index 000000000..00d054e9a --- /dev/null +++ b/ethernet-view/src/components/FormComponents/Button/Button.module.scss @@ -0,0 +1,41 @@ +@use "src/styles/styles"; +@use "src/styles/colors" as colors; + +.buttonWrapper { + flex: 1 1 0; + display: flex; + justify-content: center; + align-items: center; + + padding: 0.5rem; + text-align: center; + border-radius: styles.$normal-border-radius; + background-color: colors.getThemeColor("secondary"); + color: colors.getThemeColor("surface"); + user-select: none; + + @include styles.shadow; + + &.enabled { + cursor: pointer; + + &:hover { + background-color: colors.getThemeColor("secondary-hover"); + } + } + + &.disabled { + cursor: auto; + background-color: colors.getThemeColor("border"); + color: colors.getThemeColor("text-disabled"); + } +} + +.label { + overflow: hidden; + text-overflow: ellipsis; +} + +.icon { + width: 75%; +} \ No newline at end of file diff --git a/ethernet-view/src/components/FormComponents/Button/Button.tsx b/ethernet-view/src/components/FormComponents/Button/Button.tsx new file mode 100644 index 000000000..eb5d763f5 --- /dev/null +++ b/ethernet-view/src/components/FormComponents/Button/Button.tsx @@ -0,0 +1,64 @@ +import styles from "components/FormComponents/Button/Button.module.scss"; +import { animated, useSpring } from "@react-spring/web"; +import { lightenHSL } from "utils/color"; + +type Props = { + label?: string; + icon?: string; + onClick?: (ev: React.MouseEvent) => void; + disabled?: boolean; + color?: string; + className?: string; +}; + +export const Button = ({ + label = undefined, + icon = undefined, + color = "hsl(29, 88%, 57%)", + onClick = () => {}, + disabled = false, + className = "", +}: Props) => { + const [springs, api] = useSpring(() => ({ + from: { backgroundColor: color }, + config: { + mass: 5, + tension: 3000, + friction: 1, + clamp: true, + }, + })); + + return ( + { + if (!disabled) { + onClick(ev); + } + ev.stopPropagation(); + }} + style={!disabled ? { ...springs } : {}} + onMouseDown={() => + api.start({ + to: { backgroundColor: lightenHSL(color, 15) }, + }) + } + onMouseLeave={() => + api.start({ + to: { backgroundColor: color }, + }) + } + onMouseUp={() => + api.start({ + to: { backgroundColor: color }, + }) + } + > + {icon && icon} + {label} + + ); +}; diff --git a/ethernet-view/src/components/FormComponents/CheckBox/CheckBox.module.scss b/ethernet-view/src/components/FormComponents/CheckBox/CheckBox.module.scss new file mode 100644 index 000000000..54dcfb39e --- /dev/null +++ b/ethernet-view/src/components/FormComponents/CheckBox/CheckBox.module.scss @@ -0,0 +1,19 @@ +@use "src/styles/styles"; +@use "src/styles/colors" as colors; + +#checkBox { + width: 1rem; + height: 1rem; + border: 1px solid colors.getThemeColor("border"); + background-color: colors.getThemeColor("input-bg"); + + &:checked { + background-color: colors.getThemeColor("primary"); + border-color: colors.getThemeColor("primary"); + } + + &:focus { + outline: none; + box-shadow: 0 0 0 2px colors.getThemeColor("primary-surface"); + } +} diff --git a/ethernet-view/src/components/FormComponents/CheckBox/CheckBox.tsx b/ethernet-view/src/components/FormComponents/CheckBox/CheckBox.tsx new file mode 100644 index 000000000..6a4904bcf --- /dev/null +++ b/ethernet-view/src/components/FormComponents/CheckBox/CheckBox.tsx @@ -0,0 +1,35 @@ +import styles from "components/FormComponents/CheckBox/CheckBox.module.scss"; +import { ChangeEvent, useState } from "react"; + +type Props = { + isRequired: boolean; + onChange: (value: boolean) => void; + disabled?: boolean; + initialValue?: boolean; + color?: string; +}; + +export const CheckBox = ({ + onChange, + disabled = false, + isRequired, + initialValue = false, + color, +}: Props) => { + const [checked, setChecked] = useState(initialValue); + + return ( + ) => { + onChange(Boolean(ev.target.checked)); + setChecked(Boolean(ev.target.checked)); + }} + id={styles.checkBox} + checked={checked} + /> + ); +}; diff --git a/ethernet-view/src/components/FormComponents/Dropdown/Dropdown.module.scss b/ethernet-view/src/components/FormComponents/Dropdown/Dropdown.module.scss new file mode 100644 index 000000000..6aa7f36e6 --- /dev/null +++ b/ethernet-view/src/components/FormComponents/Dropdown/Dropdown.module.scss @@ -0,0 +1,29 @@ +@use "src/styles/styles"; +@use "src/styles/colors" as colors; + +.select { + width: 100%; + padding: 0.5rem; + background-color: rgba(235, 137, 33, 0.1); + color: colors.getThemeColor("text-primary"); + border: 1px solid colors.getThemeColor("border"); + border-radius: styles.$normal-border-radius; + + &:focus { + outline: none; + border-color: colors.getThemeColor("primary"); + } +} + +:global([data-theme="dark"]) .select { + background-color: rgba(235, 137, 33, 0.05); +} + +option { + background-color: rgba(235, 137, 33, 0.1); + color: colors.getThemeColor("text-primary"); +} + +:global([data-theme="dark"]) option { + background-color: rgba(235, 137, 33, 0.05); +} diff --git a/ethernet-view/src/components/FormComponents/Dropdown/Dropdown.tsx b/ethernet-view/src/components/FormComponents/Dropdown/Dropdown.tsx new file mode 100644 index 000000000..9fe3af2c7 --- /dev/null +++ b/ethernet-view/src/components/FormComponents/Dropdown/Dropdown.tsx @@ -0,0 +1,29 @@ +import styles from "components/FormComponents/Dropdown/Dropdown.module.scss"; + +type Props = { + value?: string; + options: string[]; + onChange: (newValue: string) => void; +}; + +export const Dropdown = ({ value, options, onChange }: Props) => { + return ( + + ); +}; diff --git a/ethernet-view/src/components/FormComponents/FileInput/FileInput.tsx b/ethernet-view/src/components/FormComponents/FileInput/FileInput.tsx new file mode 100644 index 000000000..4a458596b --- /dev/null +++ b/ethernet-view/src/components/FormComponents/FileInput/FileInput.tsx @@ -0,0 +1,34 @@ +import { useId } from "react"; + +type Props = { + label: string; + accept: string; + onFile: (file: File) => void; + className: string; + children?: React.ReactNode; +}; + +export const FileInput = ({ accept, className, label, onFile }: Props) => { + const id = useId(); + + return ( + <> + + { + if (ev.target.files) onFile(ev.target.files[0]); + }} + /> + + ); +}; diff --git a/ethernet-view/src/components/FormComponents/NumericInput/NumericInput.tsx b/ethernet-view/src/components/FormComponents/NumericInput/NumericInput.tsx new file mode 100644 index 000000000..dda792a44 --- /dev/null +++ b/ethernet-view/src/components/FormComponents/NumericInput/NumericInput.tsx @@ -0,0 +1,52 @@ +import { TextInput } from "../TextInput/TextInput"; + +type Props = { + required: boolean; + defaultValue: number | string; + placeholder: string; + disabled: boolean; + isValid: boolean; + onChange: (value: string) => void; +}; + +function getUnion(arr: string[]): string { + return arr.map((value) => `(?:${value})`).join("|"); +} + +const numericInputKeyRegex = (() => { + const arrows = ["ArrowUp", "ArrowLeft", "ArrowRight", "ArrowDown"]; + const controlKeys = ["Delete", "Backspace", "Home", "End"]; // Home es inicio + const chars = "[0-9.+-]"; + + const regexp = chars + "|" + getUnion(arrows) + "|" + getUnion(controlKeys); + + return new RegExp(regexp); +})(); + +export const NumericInput = ({ + onChange, + required, + disabled, + isValid, + placeholder, + defaultValue, +}: Props) => { + return ( + { + if (!numericInputKeyRegex.test(ev.key)) { + ev.preventDefault(); + return false; + } + }} + onChange={(ev) => { + onChange(ev.target.value); + }} + /> + ); +}; diff --git a/ethernet-view/src/components/FormComponents/TextInput/TextInput.module.scss b/ethernet-view/src/components/FormComponents/TextInput/TextInput.module.scss new file mode 100644 index 000000000..72dda4771 --- /dev/null +++ b/ethernet-view/src/components/FormComponents/TextInput/TextInput.module.scss @@ -0,0 +1,33 @@ +@use "src/styles/styles"; +@use "src/styles/colors" as colors; + +.textInput { + flex: 1 1 0; + min-width: 5rem; + padding: 0.5rem 0.8rem; + border-radius: styles.$normal-border-radius; + font: inherit; + transition: border-color 0.1s linear; + background-color: colors.getThemeColor("input-bg"); + color: colors.getThemeColor("text-primary"); + border: 1px solid colors.getThemeColor("border"); + + &:focus { + outline: none; + border-color: colors.getThemeColor("primary"); + } +} + +.valid { + border-color: colors.getThemeColor("success"); +} + +.invalid { + border-color: colors.getThemeColor("error"); +} + +.disabled { + color: colors.getThemeColor("text-disabled"); + border-color: colors.getThemeColor("border"); + background-color: colors.getThemeColor("surface-variant"); +} diff --git a/ethernet-view/src/components/FormComponents/TextInput/TextInput.tsx b/ethernet-view/src/components/FormComponents/TextInput/TextInput.tsx new file mode 100644 index 000000000..1f54daece --- /dev/null +++ b/ethernet-view/src/components/FormComponents/TextInput/TextInput.tsx @@ -0,0 +1,16 @@ +import styles from "components/FormComponents/TextInput/TextInput.module.scss"; + +type Props = { isValid: boolean } & React.InputHTMLAttributes; + +export const TextInput = ({ isValid, ...props }: Props) => { + return ( + + ); +}; diff --git a/ethernet-view/src/components/Island/Island.module.scss b/ethernet-view/src/components/Island/Island.module.scss new file mode 100644 index 000000000..30b2eb030 --- /dev/null +++ b/ethernet-view/src/components/Island/Island.module.scss @@ -0,0 +1,13 @@ +@use "src/styles/styles"; +@use "src/styles/colors" as colors; + +.island { + display: flex; + flex-direction: column; + align-items: stretch; + height: 100%; + padding: 1rem; + background-color: colors.getThemeColor("island-bg"); + border-radius: 0.8rem; + box-shadow: colors.getThemeColor("shadow-sm"); +} diff --git a/ethernet-view/src/components/Island/Island.tsx b/ethernet-view/src/components/Island/Island.tsx new file mode 100644 index 000000000..8eca94177 --- /dev/null +++ b/ethernet-view/src/components/Island/Island.tsx @@ -0,0 +1,17 @@ +import styles from "./Island.module.scss"; + +type Props = { + style?: React.CSSProperties; + children: React.ReactNode; +}; + +export const Island = ({ children, style }: Props) => { + return ( +
+ {children} +
+ ); +}; diff --git a/ethernet-view/src/components/Logger/Logger.module.scss b/ethernet-view/src/components/Logger/Logger.module.scss new file mode 100644 index 000000000..a3d104015 --- /dev/null +++ b/ethernet-view/src/components/Logger/Logger.module.scss @@ -0,0 +1,36 @@ +@use "src/styles/styles"; + +.logger { + height: min-content; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +} + +.state { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: bold; +} + +.buttons { + flex: 2; + width: 10rem; + display: flex; + flex-direction: row; + align-items: center; + gap: 1rem; + + > * { + min-width: 0; + min-height: 0; + height: 100%; + overflow: hidden; + display: flex; + align-items: center; + cursor: pointer; + } +} diff --git a/ethernet-view/src/components/Logger/Logger.tsx b/ethernet-view/src/components/Logger/Logger.tsx new file mode 100644 index 000000000..a55b4011b --- /dev/null +++ b/ethernet-view/src/components/Logger/Logger.tsx @@ -0,0 +1,43 @@ +import styles from "components/Logger/Logger.module.scss"; +import { useLogger } from "./useLogger"; +import { Island } from "components/Island/Island"; +import { Button } from "components/FormComponents/Button/Button"; +import oscilloscope from "assets/svg/oscilloscope.svg"; +import { useConfig } from "common"; + +export const Logger = () => { + + const [state, startLogging, stopLogging] = useLogger(); + const config = useConfig(); + + return ( + +
+ Logging: {`${state}`} +
+ + + + +
+
+
+ ); +}; diff --git a/ethernet-view/src/components/Logger/useLogger.ts b/ethernet-view/src/components/Logger/useLogger.ts new file mode 100644 index 000000000..c4fc57df9 --- /dev/null +++ b/ethernet-view/src/components/Logger/useLogger.ts @@ -0,0 +1,29 @@ +import { useSubscribe, useWsHandler } from "common"; +import { useState } from "react"; +import { useMeasurementsStore } from "common"; + +export function useLogger() { + const [state, setState] = useState(false); + + const handler = useWsHandler(); + + function getLoggedVariableIds() { + return useMeasurementsStore.getState().getLogVariables(); + } + + function startLogging() { + const variables = getLoggedVariableIds(); + handler.post("logger/variables", variables); + handler.post("logger/enable", true); + } + + function stopLogging() { + handler.post("logger/enable", false); + } + + useSubscribe("logger/response", (result) => { + setState(result); + }); + + return [state, startLogging, stopLogging] as const; +} diff --git a/ethernet-view/src/components/MessagesContainer/Messages/MessageView/Counter/Counter.module.scss b/ethernet-view/src/components/MessagesContainer/Messages/MessageView/Counter/Counter.module.scss new file mode 100644 index 000000000..7ac6fc00d --- /dev/null +++ b/ethernet-view/src/components/MessagesContainer/Messages/MessageView/Counter/Counter.module.scss @@ -0,0 +1,13 @@ +.counter { + width: fit-content; + height: fit-content; + border-radius: 4rem; + padding: 0.25rem 0.35rem; + + font-family: Consolas; + font-size: small; + line-height: 90%; + + color: var(--main-color); // Use main color for better contrast + background-color: var(--light-color); +} diff --git a/ethernet-view/src/components/MessagesContainer/Messages/MessageView/Counter/Counter.tsx b/ethernet-view/src/components/MessagesContainer/Messages/MessageView/Counter/Counter.tsx new file mode 100644 index 000000000..2994d7436 --- /dev/null +++ b/ethernet-view/src/components/MessagesContainer/Messages/MessageView/Counter/Counter.tsx @@ -0,0 +1,10 @@ +import styles from "./Counter.module.scss"; + +type Props = { + count: number; + className: string; +}; + +export const Counter = ({ count, className }: Props) => { + return
{count}
; +}; diff --git a/ethernet-view/src/components/MessagesContainer/Messages/MessageView/InfoMessageView/InfoMessageView.module.scss b/ethernet-view/src/components/MessagesContainer/Messages/MessageView/InfoMessageView/InfoMessageView.module.scss new file mode 100644 index 000000000..88b98cd43 --- /dev/null +++ b/ethernet-view/src/components/MessagesContainer/Messages/MessageView/InfoMessageView/InfoMessageView.module.scss @@ -0,0 +1,18 @@ +.infoMessageView { + display: flex; + flex-direction: column; + gap: 0.5rem; + min-width: 0; +} + +.board { + color: var(--main-color); + font-weight: bold; + overflow: hidden; + text-overflow: ellipsis; +} + +.payload { + font-size: 0.9rem; + color: black; // Inherit from parent which has proper theme color +} diff --git a/ethernet-view/src/components/MessagesContainer/Messages/MessageView/InfoMessageView/InfoMessageView.tsx b/ethernet-view/src/components/MessagesContainer/Messages/MessageView/InfoMessageView/InfoMessageView.tsx new file mode 100644 index 000000000..ba747e78c --- /dev/null +++ b/ethernet-view/src/components/MessagesContainer/Messages/MessageView/InfoMessageView/InfoMessageView.tsx @@ -0,0 +1,17 @@ +import styles from "./InfoMessageView.module.scss"; + +import { InfoMessage } from "common"; + +type Props = { + message: InfoMessage; + className: string; +}; + +export const InfoMessageView = ({ message, className }: Props) => { + return ( +
+
{message.board}
+
{message.payload}
+
+ ); +}; diff --git a/ethernet-view/src/components/MessagesContainer/Messages/MessageView/MessageView.module.scss b/ethernet-view/src/components/MessagesContainer/Messages/MessageView/MessageView.module.scss new file mode 100644 index 000000000..3352dfe76 --- /dev/null +++ b/ethernet-view/src/components/MessagesContainer/Messages/MessageView/MessageView.module.scss @@ -0,0 +1,60 @@ +@use "src/styles/styles"; +@use "src/styles/colors" as colors; + +//TODO: increase contrast info +.message.info { + --main-color: #{colors.getThemeColor("success")}; + --light-color: #{colors.getColor("success", 75)}; + --background-color: #{colors.getColor("success", 95)}; +} + +.message.fault { + --main-color: #{colors.getThemeColor("error")}; + --light-color: #{colors.getColor("error", 75)}; + --background-color: #{colors.getColor("error", 95)}; +} + +.message.warning { + --main-color: #{colors.getThemeColor("warning")}; + --light-color: #{colors.getColor("warning", 75)}; + --background-color: #{colors.getColor("warning", 95)}; +} + +.message { + display: grid; + grid-template: + "icon content counter" auto + "icon content counter" auto + "icon timestamp timestamp" auto / auto 1fr auto; + + gap: 0.4rem 0.8rem; + + padding: 0.6rem; + background-color: var(--background-color); + border-radius: 1rem; + @include styles.shadow; + color: colors.getThemeColor("text-primary"); +} + +.icon { + grid-area: icon; + color: var(--main-color); +} + +.counter { + grid-area: counter; +} + +.content { + grid-area: content; +} + +.idAndCounter { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.timestamp { + grid-area: timestamp; +} diff --git a/ethernet-view/src/components/MessagesContainer/Messages/MessageView/MessageView.tsx b/ethernet-view/src/components/MessagesContainer/Messages/MessageView/MessageView.tsx new file mode 100644 index 000000000..2912c69af --- /dev/null +++ b/ethernet-view/src/components/MessagesContainer/Messages/MessageView/MessageView.tsx @@ -0,0 +1,63 @@ +import styles from "./MessageView.module.scss"; +import React from "react"; +import { Counter } from "./Counter/Counter"; +import { Message } from "common"; + +import { ReactComponent as Info } from "assets/svg/info.svg"; +import { ReactComponent as Warning } from "assets/svg/warning.svg"; +import { ReactComponent as Fault } from "assets/svg/fault.svg"; + +import { TimestampView } from "./TimestampView/TimestampView"; +import { ProtectionMessageView } from "./ProtectionMessageView/ProtectionMessageView"; +import { InfoMessageView } from "./InfoMessageView/InfoMessageView"; + +type Props = { + message: Message; +}; + +const icons = { + info: Info, + fault: Fault, + warning: Warning, + ok: Info, +}; + +const appearances = { + info: styles.info, + warning: styles.warning, + fault: styles.fault, + ok: styles.info, +}; + +export const MessageView = React.memo(({ message }: Props) => { + const Icon = icons[message.kind]; + const appearance = appearances[message.kind]; + + const Message = + message.kind == "warning" || message.kind == "fault" || message.kind == "ok" ? ( + + ) : ( + + ); + + return ( +
+ + {Message} + + +
+ ); +}); diff --git a/ethernet-view/src/components/MessagesContainer/Messages/MessageView/ProtectionMessageView/Origin/Origin.module.scss b/ethernet-view/src/components/MessagesContainer/Messages/MessageView/ProtectionMessageView/Origin/Origin.module.scss new file mode 100644 index 000000000..54be8c6b0 --- /dev/null +++ b/ethernet-view/src/components/MessagesContainer/Messages/MessageView/ProtectionMessageView/Origin/Origin.module.scss @@ -0,0 +1,14 @@ +.origin { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: bold; +} + +.text { + color: var(--main-color); +} + +.arrow { + color: var(--light-color); +} diff --git a/ethernet-view/src/components/MessagesContainer/Messages/MessageView/ProtectionMessageView/Origin/Origin.tsx b/ethernet-view/src/components/MessagesContainer/Messages/MessageView/ProtectionMessageView/Origin/Origin.tsx new file mode 100644 index 000000000..11204fdb2 --- /dev/null +++ b/ethernet-view/src/components/MessagesContainer/Messages/MessageView/ProtectionMessageView/Origin/Origin.tsx @@ -0,0 +1,23 @@ +import styles from "./Origin.module.scss"; +import { ReactComponent as RightArrow } from "assets/svg/right-arrow.svg"; + +type Props = { + board: string; + name: string; + className: string; +}; + +export const Origin = ({ board, name, className }: Props) => { + return ( +
+ {board} + + + {name} + +
+ ); +}; diff --git a/ethernet-view/src/components/MessagesContainer/Messages/MessageView/ProtectionMessageView/ProtectionMessageView.module.scss b/ethernet-view/src/components/MessagesContainer/Messages/MessageView/ProtectionMessageView/ProtectionMessageView.module.scss new file mode 100644 index 000000000..636a1ffc2 --- /dev/null +++ b/ethernet-view/src/components/MessagesContainer/Messages/MessageView/ProtectionMessageView/ProtectionMessageView.module.scss @@ -0,0 +1,32 @@ +.protectionMessage { + display: grid; + grid-template-rows: auto 1fr; + gap: 0.5rem; + color: black; +} + +.kindAndOrigin { + display: flex; + align-items: center; + flex-wrap: wrap; + overflow: hidden; + + column-gap: 0.8rem; + row-gap: 0.5rem; +} + +.protectionKind { + width: fit-content; + height: fit-content; + text-overflow: ellipsis; + overflow: hidden; + padding: 0.15rem 0.4rem; + border-radius: 0.5rem; + background-color: var(--main-color); + color: white; + font-weight: bold; +} + +.origin { + overflow: hidden; +} diff --git a/ethernet-view/src/components/MessagesContainer/Messages/MessageView/ProtectionMessageView/ProtectionMessageView.tsx b/ethernet-view/src/components/MessagesContainer/Messages/MessageView/ProtectionMessageView/ProtectionMessageView.tsx new file mode 100644 index 000000000..79368b0cc --- /dev/null +++ b/ethernet-view/src/components/MessagesContainer/Messages/MessageView/ProtectionMessageView/ProtectionMessageView.tsx @@ -0,0 +1,27 @@ +import { ProtectionMessage } from "common"; +import styles from "./ProtectionMessageView.module.scss"; +import { Origin } from "./Origin/Origin"; +import { ProtectionView } from "./ProtectionView/ProtectionView"; + +type Props = { + message: ProtectionMessage; + className: string; +}; + +export const ProtectionMessageView = ({ message, className }: Props) => { + return ( +
+
+
+ {message.payload.kind} +
+ +
+ +
+ ); +}; diff --git a/ethernet-view/src/components/MessagesContainer/Messages/MessageView/ProtectionMessageView/ProtectionView/ProtectionView.module.scss b/ethernet-view/src/components/MessagesContainer/Messages/MessageView/ProtectionMessageView/ProtectionView/ProtectionView.module.scss new file mode 100644 index 000000000..0153c8e22 --- /dev/null +++ b/ethernet-view/src/components/MessagesContainer/Messages/MessageView/ProtectionMessageView/ProtectionView/ProtectionView.module.scss @@ -0,0 +1,6 @@ +.protectionView { + display: inline-flex; + flex-wrap: wrap; + column-gap: 1rem; + row-gap: 0.5rem; +} diff --git a/ethernet-view/src/components/MessagesContainer/Messages/MessageView/ProtectionMessageView/ProtectionView/ProtectionView.tsx b/ethernet-view/src/components/MessagesContainer/Messages/MessageView/ProtectionMessageView/ProtectionView/ProtectionView.tsx new file mode 100644 index 000000000..7a98e98d5 --- /dev/null +++ b/ethernet-view/src/components/MessagesContainer/Messages/MessageView/ProtectionMessageView/ProtectionView/ProtectionView.tsx @@ -0,0 +1,78 @@ +import styles from './ProtectionView.module.scss'; +import { ProtectionMessage } from 'common'; + +type Props = { + protection: ProtectionMessage; +}; + +const DECIMALS = 2; + +function safeFixed(val: any, decimals = DECIMALS) { + return typeof val === "number" ? val.toFixed(decimals) : String(val); +} + +export const ProtectionView = ({ protection }: Props) => { + const ProtectionText = getProtectionText(protection); + + return
{ProtectionText}
; +}; + +function getProtectionText(protection: ProtectionMessage) { + switch (protection.payload.kind) { + case "OUT_OF_BOUNDS": + return ( + <> + + {" "} + Want: [ + {safeFixed(protection.payload.data.bounds?.[0])}, {safeFixed(protection.payload.data.bounds?.[1])}] + {" "} + Got: {safeFixed(protection.payload.data.value)} + + ); + case "UPPER_BOUND": + return ( + <> + + Want: [{protection.name}] {"<"} {safeFixed(protection.payload.data.bound)} + {" "} + Got: {safeFixed(protection.payload.data.value)} + + ); + case "LOWER_BOUND": + return ( + <> + + Want: [{protection.name}] {">"} {safeFixed(protection.payload.data.bound)} + {" "} + Got: {safeFixed(protection.payload.data.value)} + + ); + case "EQUALS": + return ( + <> + + Mustn't be {safeFixed(protection.payload.data.value)} + + + ); + case "NOT_EQUALS": + return ( + <> + + Must be {safeFixed(protection.payload.data.want)} but is{" "} + {safeFixed(protection.payload.data.value)} + + + ); + case "TIME_ACCUMULATION": + return ( + + Value was {safeFixed(protection.payload.data.value)} for{" "} + {safeFixed(protection.payload.data.timelimit)} seconds + + ); + case "ERROR_HANDLER": + return {protection.payload.data}; + } +} diff --git a/ethernet-view/src/components/MessagesContainer/Messages/MessageView/TimestampView/TimestampView.module.scss b/ethernet-view/src/components/MessagesContainer/Messages/MessageView/TimestampView/TimestampView.module.scss new file mode 100644 index 000000000..fda725afd --- /dev/null +++ b/ethernet-view/src/components/MessagesContainer/Messages/MessageView/TimestampView/TimestampView.module.scss @@ -0,0 +1,5 @@ +.timestamp { + overflow: hidden; + text-overflow: ellipsis; + color: var(--main-color); +} diff --git a/ethernet-view/src/components/MessagesContainer/Messages/MessageView/TimestampView/TimestampView.tsx b/ethernet-view/src/components/MessagesContainer/Messages/MessageView/TimestampView/TimestampView.tsx new file mode 100644 index 000000000..59ecd1240 --- /dev/null +++ b/ethernet-view/src/components/MessagesContainer/Messages/MessageView/TimestampView/TimestampView.tsx @@ -0,0 +1,19 @@ +import styles from "./TimestampView.module.scss"; +import { Timestamp } from "common"; + +type Props = { + timestamp: Timestamp; + className: string; +}; + +export const TimestampView = ({ timestamp, className }: Props) => { + return ( +
+ {getTimestampString(timestamp)} +
+ ); +}; + +function getTimestampString(timestamp: Timestamp): string { + return `${timestamp.hour}:${timestamp.minute}:${timestamp.second} ${timestamp.day}/${timestamp.month}/${timestamp.year}`; +} diff --git a/ethernet-view/src/components/MessagesContainer/Messages/Messages.module.scss b/ethernet-view/src/components/MessagesContainer/Messages/Messages.module.scss new file mode 100644 index 000000000..5ac818e20 --- /dev/null +++ b/ethernet-view/src/components/MessagesContainer/Messages/Messages.module.scss @@ -0,0 +1,31 @@ +.messagesWrapper { + flex: 1 1 0; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 1rem; +} + +.messages { + flex: 1 1 0; + display: flex; + flex-direction: column; + align-items: start; + gap: 0.6rem; + min-height: 0; + overflow-y: auto; + height: 100%; + +} + +.buttons { + display: flex; + flex-direction: row; + justify-content: center; + gap: 1rem; +} + +.clearBtn { + flex: 0 0 auto; + height: min-content; +} diff --git a/ethernet-view/src/components/MessagesContainer/Messages/Messages.tsx b/ethernet-view/src/components/MessagesContainer/Messages/Messages.tsx new file mode 100644 index 000000000..6eb01af64 --- /dev/null +++ b/ethernet-view/src/components/MessagesContainer/Messages/Messages.tsx @@ -0,0 +1,48 @@ +import { Message } from "common"; +import styles from "./Messages.module.scss"; +import { MessageView } from "./MessageView/MessageView"; +import { useAutoScroll } from "./useAutoScroll"; +import { Button } from "components/FormComponents/Button/Button"; +import { useMessagesStore } from "common"; + +type Props = { + messages: Message[]; +}; + +export const Messages = ({ messages }: Props) => { + const { ref, handleScroll } = useAutoScroll(messages); + const clearMessages = useMessagesStore((state) => state.clearMessages); + + return ( +
+
+ + { messages.map((message) => ( + + )) } +
+
+
+
+ ); +}; diff --git a/ethernet-view/src/components/MessagesContainer/Messages/useAutoScroll.ts b/ethernet-view/src/components/MessagesContainer/Messages/useAutoScroll.ts new file mode 100644 index 000000000..93efe6a10 --- /dev/null +++ b/ethernet-view/src/components/MessagesContainer/Messages/useAutoScroll.ts @@ -0,0 +1,41 @@ +import { useLayoutEffect, useRef } from "react"; + +const AUTO_THRESHOLD = 30; + +export function useAutoScroll(items: Array) { + const ref = useRef(null); + const autoScroll = useRef(true); + const prevScrollTop = useRef(); + + useLayoutEffect(() => { + if (ref.current && autoScroll.current) { + ref.current.scroll({ + top: ref.current.scrollHeight, + behavior: "auto", + }); + } + }, [items]); + + function handleScroll(ev: React.UIEvent) { + // If user scrolls up, auto = false + if ( + prevScrollTop.current && + ev.currentTarget.scrollTop < prevScrollTop.current + ) { + autoScroll.current = false; + } else if ( + // If user scroll to the bottom, auto = true + Math.abs( + ev.currentTarget.scrollTop + + ev.currentTarget.offsetHeight - + ev.currentTarget.scrollHeight + ) < AUTO_THRESHOLD + ) { + autoScroll.current = true; + } + + prevScrollTop.current = ev.currentTarget.scrollTop; + } + + return { ref, handleScroll }; +} diff --git a/ethernet-view/src/components/MessagesContainer/MessagesContainer.module.scss b/ethernet-view/src/components/MessagesContainer/MessagesContainer.module.scss new file mode 100644 index 000000000..e8825e328 --- /dev/null +++ b/ethernet-view/src/components/MessagesContainer/MessagesContainer.module.scss @@ -0,0 +1,7 @@ +@use "src/styles/colors" as colors; + +.emptyAlert { + margin-top: 2rem; + text-align: center; + color: colors.getThemeColor("text-tertiary"); +} diff --git a/ethernet-view/src/components/MessagesContainer/MessagesContainer.tsx b/ethernet-view/src/components/MessagesContainer/MessagesContainer.tsx new file mode 100644 index 000000000..10d578c63 --- /dev/null +++ b/ethernet-view/src/components/MessagesContainer/MessagesContainer.tsx @@ -0,0 +1,7 @@ +import { Messages } from "./Messages/Messages"; +import { useMessages } from "components/MessagesContainer/useMessages"; + +export const MessagesContainer = () => { + const messages = useMessages(); + return ; +}; diff --git a/ethernet-view/src/components/MessagesContainer/useMessages.ts b/ethernet-view/src/components/MessagesContainer/useMessages.ts new file mode 100644 index 000000000..0f350a15b --- /dev/null +++ b/ethernet-view/src/components/MessagesContainer/useMessages.ts @@ -0,0 +1,10 @@ +import { MessageAdapter, useSubscribe } from "common"; +import { useMessagesStore } from "common"; + +export function useMessages() { + const { messages, addMessage } = useMessagesStore(state => ({messages: state.messages, addMessage: state.addMessage})); + + useSubscribe("message/update", (msg: MessageAdapter) => addMessage(msg)); + + return messages; +} diff --git a/ethernet-view/src/components/Navbar/Navbar.module.scss b/ethernet-view/src/components/Navbar/Navbar.module.scss new file mode 100644 index 000000000..801242650 --- /dev/null +++ b/ethernet-view/src/components/Navbar/Navbar.module.scss @@ -0,0 +1,40 @@ +@use "../../styles/styles.scss"; +@use "../../styles/colors.scss" as colors; + +.navbarWrapper { + width: fit-content; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + gap: 1.4rem; + padding: 2rem 0.8rem; + background-color: colors.getThemeColor("navbar-bg"); + border-right: 1px solid colors.getThemeColor("border"); + border-radius: styles.$large-border-radius; + min-height: 100%; +} + +.logo { + font-size: 3rem; + color: colors.getThemeColor("primary"); +} + +.separator { + width: 46%; + height: 0.25rem; + border-radius: 10rem; + background-color: colors.getThemeColor("border"); +} + +.items { + display: flex; + flex-direction: column; + align-items: center; + font-size: 1.5rem; + gap: 1.5rem; +} + +.spacer { + flex: 1; +} diff --git a/ethernet-view/src/components/Navbar/Navbar.tsx b/ethernet-view/src/components/Navbar/Navbar.tsx new file mode 100644 index 000000000..43e16dfbe --- /dev/null +++ b/ethernet-view/src/components/Navbar/Navbar.tsx @@ -0,0 +1,42 @@ +import styles from "./Navbar.module.scss"; +import { NavbarItem, NavbarItemData } from "./NavbarItem/NavbarItem"; +import { ThemeToggle } from "../ThemeToggle/ThemeToggle"; +import { ConfigPopup } from "components/ConfigPopup/ConfigPopup"; +import { useState } from "react"; + +type Props = { + items: NavbarItemData[]; + pageShown: string; + setPageShown: (page: string) => void; +}; + +export const Navbar = ({ items, pageShown, setPageShown }: Props) => { + const [isConfigOpen, setIsConfigOpen] = useState(false); + + return ( + + ); +}; diff --git a/ethernet-view/src/components/Navbar/NavbarItem/NavbarItem.module.scss b/ethernet-view/src/components/Navbar/NavbarItem/NavbarItem.module.scss new file mode 100644 index 000000000..8ed3833a0 --- /dev/null +++ b/ethernet-view/src/components/Navbar/NavbarItem/NavbarItem.module.scss @@ -0,0 +1,18 @@ +@use "src/styles/styles"; +@use "src/styles/colors" as colors; + +.iconWrapper { + cursor: pointer; + > * { + width: 2rem; + transition: filter 0.2s ease; + } + + &:hover > * { + filter: brightness(1.2); + } +} + +.active { + filter: brightness(0) saturate(100%) invert(69%) sepia(35%) saturate(2228%) hue-rotate(336deg) brightness(97%) contrast(96%); +} diff --git a/ethernet-view/src/components/Navbar/NavbarItem/NavbarItem.tsx b/ethernet-view/src/components/Navbar/NavbarItem/NavbarItem.tsx new file mode 100644 index 000000000..77315fdab --- /dev/null +++ b/ethernet-view/src/components/Navbar/NavbarItem/NavbarItem.tsx @@ -0,0 +1,28 @@ +import styles from "components/Navbar/NavbarItem/NavbarItem.module.scss"; + +export type NavbarItemData = { + icon: string; + page: string; + +}; + +type Props = { + item: NavbarItemData; + pageShown: string; + setPageShown: (page: string) => void; +}; + +export const NavbarItem = ({ item, pageShown, setPageShown }: Props) => { + + const handleClick = () => { + setPageShown(item.page); + } + + return ( +
+
+ icon +
+
+ ); +}; diff --git a/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/BoardOrders.module.scss b/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/BoardOrders.module.scss new file mode 100644 index 000000000..e4ba258a0 --- /dev/null +++ b/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/BoardOrders.module.scss @@ -0,0 +1,41 @@ +@use "src/styles/styles"; +@use "src/styles/colors" as colors; + +.boardOrders { + display: flex; + flex-direction: column; + gap: 0.8rem; +} + +.name { + display: flex; + gap: 0.6rem; + font-family: Inter; + font-weight: 600; + color: hsl(29, 88%, 70%); + cursor: pointer; + padding: 0.5rem; + border-radius: 0.25rem; + + &:hover { + background-color: rgba(235, 137, 33, 0.15); + } +} + +:global([data-theme="dark"]) .name { + &:hover { + background-color: rgba(235, 137, 33, 0.08); + } +} + +.orders, +.stateOrders { + display: flex; + flex-direction: column; + gap: 0.6rem; + + .title { + color: rgb(145, 145, 145); + font-weight: 600; + } +} diff --git a/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/BoardOrders.tsx b/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/BoardOrders.tsx new file mode 100644 index 000000000..5a6a095d6 --- /dev/null +++ b/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/BoardOrders.tsx @@ -0,0 +1,60 @@ +import { BoardOrders } from "common"; +import styles from "./BoardOrders.module.scss"; +import { OrderForm } from "./OrderForm/OrderForm"; +import { useState } from "react"; +import { Caret } from "components/Caret/Caret"; + +type Props = { + boardOrders: BoardOrders; + alwaysShowStateOrders: boolean; +}; + +export const BoardOrdersView = ({ + boardOrders, + alwaysShowStateOrders, +}: Props) => { + + const [isOpen, setIsOpen] = useState(false); + + return ( +
+
setIsOpen((prev) => !prev)} > + + {boardOrders.name} +
+ +
+ Permanent orders + {boardOrders.orders.map((desc) => { + return ( + + ); + })} +
+ + {(boardOrders.stateOrders.length > 0 && + (alwaysShowStateOrders || boardOrders.stateOrders.some((item) => item.enabled))) && ( +
+ State orders + {boardOrders.stateOrders.map((desc) => { + if (alwaysShowStateOrders || desc.enabled) { + return ( + + ); + } else { + return false; + } + })} +
+ )} +
+ ); +}; diff --git a/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/Fields/Field/Field.module.scss b/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/Fields/Field/Field.module.scss new file mode 100644 index 000000000..5fe5bad44 --- /dev/null +++ b/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/Fields/Field/Field.module.scss @@ -0,0 +1,19 @@ +@use "src/styles/styles"; +@use "src/styles/colors" as colors; + +.fieldWrapper { + width: 100%; + display: grid; + grid-template-columns: minmax(10rem, 1fr) 1fr auto; //TODO: show tooltip in names (to account for overflow) + gap: 1rem; + align-items: center; +} + +.name { + text-overflow: ellipsis; + overflow: hidden; +} + +.disabled { + color: colors.getThemeColor("text-disabled"); +} diff --git a/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/Fields/Field/Field.tsx b/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/Fields/Field/Field.tsx new file mode 100644 index 000000000..6f06d08cb --- /dev/null +++ b/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/Fields/Field/Field.tsx @@ -0,0 +1,74 @@ +import styles from "./Field.module.scss"; +import { CheckBox } from "components/FormComponents/CheckBox/CheckBox"; +import { Dropdown } from "components/FormComponents/Dropdown/Dropdown"; +import { NumericType } from "common"; +import { isNumberValid } from "./validation"; +import { NumericInput } from "components/FormComponents/NumericInput/NumericInput"; +import { FormField } from "../../form"; + +type Props = { + name: string; + field: FormField; + onChange: (newValue: boolean | string | number, isValid: boolean) => void; + changeEnabled: (isEnabled: boolean) => void; +}; + +export const Field = ({ name, field, onChange, changeEnabled }: Props) => { + function handleTextInputChange( + value: string, + type: NumericType, + range: [number | null, number | null] + ) { + const isValid = isNumberValid(value, type, range); + onChange(Number.parseFloat(value), isValid); + } + + return ( +
+
{name}
+ {field.kind == "numeric" ? ( + + handleTextInputChange( + value, + field.type, + field.safeRange + ) + } + /> + ) : field.kind == "boolean" ? ( + { + onChange(value, true); + }} + /> + ) : ( + { + onChange(newValue, true); + }} + /> + )} + + +
+ ); +}; diff --git a/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/Fields/Field/validation.ts b/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/Fields/Field/validation.ts new file mode 100644 index 000000000..975d7e568 --- /dev/null +++ b/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/Fields/Field/validation.ts @@ -0,0 +1,111 @@ +import { + NumericType, + isSignedIntegerType, + isUnsignedIntegerType, +} from "BackendTypes"; + +export function isNumberValid( + valueStr: string, + numberType: NumericType, + range: [number | null, number | null] +): boolean { + if (stringIsNumber(valueStr, numberType)) { + if (isUnsignedIntegerType(numberType)) { + let isValid = true; + if (range[0]) { + isValid &&= Number.parseInt(valueStr) >= range[0]; + } + + if (range[1]) { + isValid &&= Number.parseInt(valueStr) <= range[1]; + } + + return ( + isValid && + checkUnsignedIntegerOverflow( + Number.parseInt(valueStr), + getBits(numberType) + ) + ); + } else if (isSignedIntegerType(numberType)) { + let isValid = true; + if (range[0]) { + isValid &&= Number.parseInt(valueStr) >= range[0]; + } + + if (range[1]) { + isValid &&= Number.parseInt(valueStr) <= range[1]; + } + + return ( + isValid && + checkSignedIntegerOverflow( + Number.parseInt(valueStr), + getBits(numberType) + ) + ); + } else { + let isValid = true; + if (range[0]) { + isValid &&= Number.parseFloat(valueStr) >= range[0]; + } + + if (range[1]) { + isValid &&= Number.parseFloat(valueStr) <= range[1]; + } + + return isValid && checkFloatOverflow(Number.parseFloat(valueStr)); + } + } else { + return false; + } +} + +function stringIsNumber(valueStr: string, numberType: NumericType): boolean { + if (isUnsignedIntegerType(numberType)) { + return /^\d+$/.test(valueStr); + } else if (isSignedIntegerType(numberType)) { + return /^-?\d+$/.test(valueStr); + } else { + return /^-?\d+(?:\.\d+)?$/.test(valueStr); + } +} + +function checkUnsignedIntegerOverflow(value: number, bits: number): boolean { + return value >= 0 && value <= Math.pow(2, bits) - 1; +} + +function checkSignedIntegerOverflow(value: number, bits: number): boolean { + const min = -Math.pow(2, bits - 1); + const max = Math.pow(2, bits - 1) - 1; + return value >= min && value <= max; +} + +function checkFloatOverflow(value: number): boolean { + return !Number.isNaN(value); +} + +function getBits(type: NumericType): number { + switch (type) { + case "uint8": + return 8; + case "uint16": + return 16; + case "uint32": + return 32; + case "uint64": + return 64; + case "int8": + return 8; + case "int16": + return 16; + case "int32": + return 32; + case "int64": + return 64; + case "float32": + return 32; + case "float64": + return 64; + } +} diff --git a/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/Fields/Fields.module.scss b/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/Fields/Fields.module.scss new file mode 100644 index 000000000..f2b0dc058 --- /dev/null +++ b/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/Fields/Fields.module.scss @@ -0,0 +1,17 @@ +@use "src/styles/styles"; + +.fieldsWrapper { + width: 100%; + padding: 1rem; + border-top: 1px solid styles.$orange; + + display: flex; + flex-direction: column; + gap: 1rem; + + overflow-x: auto; +} + +:global([data-theme="dark"]) .fieldsWrapper { + border-top-color: rgba(235, 137, 33, 0.8); +} diff --git a/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/Fields/Fields.tsx b/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/Fields/Fields.tsx new file mode 100644 index 000000000..e2a53d5fe --- /dev/null +++ b/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/Fields/Fields.tsx @@ -0,0 +1,36 @@ +import styles from "./Fields.module.scss"; +import { Field } from "./Field/Field"; +import { FormField } from "../form"; + +type Props = { + fields: FormField[]; + updateField: ( + id: string, + value: boolean | string | number, + isValid: boolean + ) => void; + changeEnable: (id: string, isEnabled: boolean) => void; +}; + +export const Fields = ({ fields, updateField, changeEnable }: Props) => { + return ( +
+ {fields.map((field) => { + return ( + { + updateField(field.id, newValue, isValid); + }} + changeEnabled={(value) => changeEnable(field.id, value)} + /> + ); + })} +
+ ); +}; diff --git a/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/Header/Header.module.scss b/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/Header/Header.module.scss new file mode 100644 index 000000000..d9a6a83b0 --- /dev/null +++ b/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/Header/Header.module.scss @@ -0,0 +1,81 @@ +@use "src/styles/styles"; +@use "src/styles/colors" as colors; + +.headerWrapper { + flex: 0 0 0; + padding: 0.5rem; + display: grid; + grid-template: + "caret name target button" auto + / auto 1fr auto 5rem; + align-items: center; + gap: 0.5rem; + background-color: #ffe4cc; + cursor: pointer; +} + +:global([data-theme="dark"]) .headerWrapper { + background-color: rgba(235, 137, 33, 0.2); +} + +.caret { + grid-area: caret; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; +} + +.caret > * { + color: styles.$orange; +} + +.visible { + visibility: visible; +} + +.hidden { + visibility: hidden; +} + +.name { + grid-area: name; + color: styles.$orange; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; +} + +:global([data-theme="dark"]) .name, +:global([data-theme="dark"]) .caret > * { + color: #f5b575; +} + +.target { + grid-area: target; + color: colors.getThemeColor("error"); + font-size: 0.8rem; + margin: 0 0.5rem; + opacity: 0; + cursor: pointer; + transition: opacity 0.07s linear; +} + +.targetVisible { + opacity: 1; +} + +.headerWrapper:hover { + .target:not(.targetVisible) { + opacity: 0.5; + } +} + +.sendBtn { + grid-area: button; + height: 100%; + button { + height: 100%; + } +} diff --git a/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/Header/Header.tsx b/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/Header/Header.tsx new file mode 100644 index 000000000..c61d7c97f --- /dev/null +++ b/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/Header/Header.tsx @@ -0,0 +1,82 @@ +import styles from './Header.module.scss'; +import { useState, useEffect } from 'react'; +import { Button } from 'components/FormComponents/Button/Button'; +import { Caret } from 'components/Caret/Caret'; +import { SpringValue, animated } from '@react-spring/web'; +import { ReactComponent as Target } from 'assets/svg/target.svg'; + +export type HeaderInfo = ToggableHeader | FixedHeader; + +type ToggableHeader = { + type: 'toggable'; + isOpen: boolean; + toggleDropdown: () => void; +}; + +type FixedHeader = { + type: 'fixed'; +}; + +type Props = { + name: string; + disabled: boolean; + info: HeaderInfo; + springs: Record; + onTargetClick: (state: boolean) => void; + onButtonClick: () => void; +}; + +export const Header = ({ + name, + disabled, + info, + springs, + onTargetClick, + onButtonClick, +}: Props) => { + const [targetOn, setTargetOn] = useState(false); + + useEffect(() => { + onTargetClick(targetOn); + }, [targetOn]); + + return ( + {}} + style={{ + ...springs, + cursor: info.type == 'toggable' ? 'pointer' : 'auto', + }} + > + +
{name}
+ { + ev.stopPropagation(); + setTargetOn((prev) => { + return !prev; + }); + }} + /> +
+
+
+ ); +}; diff --git a/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/OrderForm.module.scss b/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/OrderForm.module.scss new file mode 100644 index 000000000..73190fcf7 --- /dev/null +++ b/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/OrderForm.module.scss @@ -0,0 +1,19 @@ +@use "src/styles/styles"; +@use "src/styles/colors" as colors; + +.orderFormWrapper { + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: stretch; + @include styles.code-text; + border: 1px solid styles.$orange; + border-radius: 0.5rem; + overflow: hidden; + + @include styles.shadow; +} + +:global([data-theme="dark"]) .orderFormWrapper { + border-color: rgba(235, 137, 33, 0.8); +} diff --git a/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/OrderForm.tsx b/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/OrderForm.tsx new file mode 100644 index 000000000..227c75a5d --- /dev/null +++ b/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/OrderForm.tsx @@ -0,0 +1,87 @@ +import styles from './OrderForm.module.scss'; +import { OrderDescription } from 'common'; +import { Header, HeaderInfo } from './Header/Header'; +import { Fields } from './Fields/Fields'; +import { useContext, useState } from 'react'; +import { Order } from 'common'; +import { useForm } from './useForm'; +import { useSpring } from '@react-spring/web'; +import { useListenKey } from './useListenKey'; +import { OrderContext } from '../../OrderContext'; +import { FormField } from './form'; + +type Props = { + description: OrderDescription; +}; + +function createOrder(id: number, fields: FormField[]): Order { + return { + id: id, + fields: Object.fromEntries( + fields.map((field) => { + return [ + field.id, + { + value: field.value, + isEnabled: field.isEnabled, + type: field.type, + }, + ]; + }) + ), + }; +} + +export const OrderForm = ({ description }: Props) => { + const sendOrder = useContext(OrderContext); + const { form, updateField, changeEnable } = useForm(description.fields); + const [isOpen, setIsOpen] = useState(false); + const [springs, api] = useSpring(() => ({ + from: { filter: 'brightness(1)' }, + config: { + tension: 600, + }, + })); + + const trySendOrder = () => { + if (form.isValid) { + api.start({ + from: { filter: 'brightness(1.2)' }, + to: { filter: 'brightness(1)' }, + }); + + sendOrder(createOrder(description.id, form.fields)); + } + }; + + const listen = useListenKey(' ', trySendOrder); + + const headerInfo: HeaderInfo = + form.fields.length > 0 + ? { + type: 'toggable', + isOpen: isOpen, + toggleDropdown: () => setIsOpen((prevValue) => !prevValue), + } + : { type: 'fixed' }; + + return ( +
+
+ {isOpen && ( + + )} +
+ ); +}; diff --git a/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/form.ts b/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/form.ts new file mode 100644 index 000000000..3c2181f29 --- /dev/null +++ b/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/form.ts @@ -0,0 +1,102 @@ +import { + BooleanDescription, + EnumDescription, + NumericDescription, + OrderFieldDescription, +} from "common"; + +export type FormField = NumericField | BooleanField | EnumField; + +type AbstractFormField = { + id: string; + isValid: boolean; + isEnabled: boolean; +}; + +export type NumericField = AbstractFormField & + NumericDescription & { + value: number; + }; + +export type BooleanField = AbstractFormField & + BooleanDescription & { + value: boolean; + }; + +export type EnumField = AbstractFormField & + EnumDescription & { + value: string; + }; + +export type Form = { + fields: FormField[]; + isValid: boolean; +}; + +export function areFieldsValid(fields: Array): boolean { + return fields.reduce((prevValid, currentField) => { + return ( + prevValid && + ((currentField.isEnabled && currentField.isValid) || + !currentField.isEnabled) + ); + }, true); +} + +export function createForm( + descriptions: Record +): Form { + const fields = Object.entries(descriptions).map(([_, fieldDescription]) => { + const field = getFormField(fieldDescription); + return field; + }); + + return { fields, isValid: areFieldsValid(fields) }; +} + +function getFormField(desc: OrderFieldDescription): FormField { + if (desc.kind == "numeric") { + return getNumericFormField(desc); + } else if (desc.kind == "boolean") { + return getBooleanFormField(desc); + } else { + return getEnumFormField(desc); + } +} + +function getNumericFormField(desc: NumericDescription): NumericField { + return { + id: desc.id, + name: desc.name, + kind: desc.kind, + type: desc.type, + safeRange: desc.safeRange, + warningRange: desc.warningRange, + value: 0, + isValid: false, + isEnabled: true, + }; +} +function getBooleanFormField(desc: BooleanDescription): BooleanField { + return { + id: desc.id, + name: desc.name, + kind: desc.kind, + type: desc.type, + value: false, + isValid: true, + isEnabled: true, + }; +} +function getEnumFormField(desc: EnumDescription): EnumField { + return { + id: desc.id, + name: desc.name, + kind: desc.kind, + type: desc.type, + options: desc.options, + value: desc.options[0], + isValid: true, + isEnabled: true, + }; +} diff --git a/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/useForm.ts b/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/useForm.ts new file mode 100644 index 000000000..df9edeb4f --- /dev/null +++ b/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/useForm.ts @@ -0,0 +1,78 @@ +import { OrderFieldDescription } from "common"; +import { useReducer } from "react"; +import { Form, areFieldsValid, createForm, FormField } from "./form"; + +type Action = UpdateField | ChangeEnable; + +type UpdateField = { + type: "update_field"; + payload: { + id: string; + isValid: boolean; + value: number | string | boolean; + }; +}; + +type ChangeEnable = { + type: "change_enable"; + payload: { + id: string; + enable: boolean; + }; +}; + +function reducer(state: Form, action: Action): Form { + switch (action.type) { + case "update_field": { + const fields: FormField[] = state.fields.map((field) => { + if (field.id == action.payload.id) { + return { + ...field, + value: action.payload.value, + isValid: action.payload.isValid, + }; + } + return field; + }) as FormField[]; + + return { + fields, + isValid: areFieldsValid(fields), + }; + } + + case "change_enable": { + const fields = state.fields.map((field) => + field.id == action.payload.id + ? { ...field, isEnabled: action.payload.enable } + : field + ); + return { + fields, + isValid: areFieldsValid(fields), + }; + } + } +} + +export function useForm(descriptions: Record) { + const [form, dispatch] = useReducer(reducer, descriptions, createForm); + + const updateField = ( + id: string, + value: string | boolean | number, + isValid: boolean + ) => + dispatch({ + type: "update_field", + payload: { id, value, isValid }, + }); + + const changeEnable = (id: string, enable: boolean) => + dispatch({ + type: "change_enable", + payload: { id, enable }, + }); + + return { form, updateField, changeEnable } as const; +} diff --git a/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/useListenKey.ts b/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/useListenKey.ts new file mode 100644 index 000000000..d4150974f --- /dev/null +++ b/ethernet-view/src/components/OrdersContainer/Orders/BoardOrders/OrderForm/useListenKey.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from "react"; + +export function useListenKey(key: string, callback: () => unknown) { + const [listen, setListen] = useState(false); + + const listener = (ev: KeyboardEvent) => { + if (ev.key == key) { + ev.preventDefault(); + callback(); + } + }; + + useEffect(() => { + if (listen) { + document.addEventListener("keydown", listener); + } + + return () => { + document.removeEventListener("keydown", listener); + }; + }, [listen, key, listener, callback]); + + return (value: boolean) => { + setListen(value); + }; +} diff --git a/ethernet-view/src/components/OrdersContainer/Orders/OrderContext.ts b/ethernet-view/src/components/OrdersContainer/Orders/OrderContext.ts new file mode 100644 index 000000000..4e67398ed --- /dev/null +++ b/ethernet-view/src/components/OrdersContainer/Orders/OrderContext.ts @@ -0,0 +1,4 @@ +import { Order } from "common"; +import { createContext } from "react"; + +export const OrderContext = createContext<(order: Order) => void>(() => {}); diff --git a/ethernet-view/src/components/OrdersContainer/Orders/Orders.module.scss b/ethernet-view/src/components/OrdersContainer/Orders/Orders.module.scss new file mode 100644 index 000000000..a4a7baafb --- /dev/null +++ b/ethernet-view/src/components/OrdersContainer/Orders/Orders.module.scss @@ -0,0 +1,45 @@ +@use "src/styles/colors" as colors; + +.ordersWrapper { + width: 100%; + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; + overflow-y: auto; + gap: 0.8rem; +} + +.stateOrdersToggle { + top: 0px; + position: sticky; + display: flex; + flex-direction: row; + gap: 0.2rem; + z-index: 1; + background-color: colors.getThemeColor("surface"); + align-items: center; +} + +.stateOrdersToggleButton { + font-size: 0.6rem; + padding: 0.15rem 0.3rem; + height: auto; + min-height: auto; +} + +.boardOrderList { + display: flex; + flex-direction: column; + gap: 0.8rem; +} + +.boardOrderList > :not(:last-child)::after { + content: ""; + border-bottom: 1px solid rgba(235, 137, 33, 0.368); // Orange with transparency + margin-bottom: 0.8rem; +} + +:global([data-theme="dark"]) .boardOrderList > :not(:last-child)::after { + border-bottom-color: rgba(235, 137, 33, 0.25); +} diff --git a/ethernet-view/src/components/OrdersContainer/Orders/Orders.tsx b/ethernet-view/src/components/OrdersContainer/Orders/Orders.tsx new file mode 100644 index 000000000..0073c4268 --- /dev/null +++ b/ethernet-view/src/components/OrdersContainer/Orders/Orders.tsx @@ -0,0 +1,50 @@ +import styles from './Orders.module.scss'; +import { BoardOrders, Button } from 'common'; +import { OrderContext } from './OrderContext'; +import { useSendOrder } from '../useSendOrder'; +import { BoardOrdersView } from './BoardOrders/BoardOrders'; +import { useState } from 'react'; + +type Props = { + boards: BoardOrders[]; +}; + +export const Orders = ({ boards }: Props) => { + const sendOrder = useSendOrder(); + const [alwaysShowStateOrders, setAlwaysShowStateOrders] = useState(false); + + return ( + +
+
+ Always show state orders:{' '} +
+
+ {boards.map((board) => { + return ( + (board.orders.length > 0 || + board.stateOrders.length > 0) && ( + + ) + ); + })} +
+
+
+ ); +}; diff --git a/ethernet-view/src/components/OrdersContainer/OrdersContainer.module.scss b/ethernet-view/src/components/OrdersContainer/OrdersContainer.module.scss new file mode 100644 index 000000000..946612df1 --- /dev/null +++ b/ethernet-view/src/components/OrdersContainer/OrdersContainer.module.scss @@ -0,0 +1,13 @@ +.orderTableWrapper { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; +} + +.emptyAlert { + margin-top: 2rem; + text-align: center; + color: rgb(156, 156, 156); +} diff --git a/ethernet-view/src/components/OrdersContainer/OrdersContainer.tsx b/ethernet-view/src/components/OrdersContainer/OrdersContainer.tsx new file mode 100644 index 000000000..78ab56754 --- /dev/null +++ b/ethernet-view/src/components/OrdersContainer/OrdersContainer.tsx @@ -0,0 +1,33 @@ +import styles from "./OrdersContainer.module.scss"; +import { Orders } from "./Orders/Orders"; +import { useConfig, useFetchBack } from "common"; +import { useEffect } from "react"; +import { useOrders } from "common"; +import { useOrdersStore } from "common"; + +export const OrdersContainer = () => { + const config = useConfig(); + const setOrders = useOrdersStore((state) => state.setOrders); + + const orderDescriptionPromise = useFetchBack( + import.meta.env.PROD, + config.paths.orderDescription + ); + useEffect(() => { + orderDescriptionPromise.then((desc) => setOrders(desc)); + }, []); + + const orders = useOrders(); + + return ( +
+ {orders.length == 0 ? ( + + Orders added to ADE will appear here + + ) : ( + + )} +
+ ); +}; diff --git a/ethernet-view/src/components/OrdersContainer/useSendOrder.ts b/ethernet-view/src/components/OrdersContainer/useSendOrder.ts new file mode 100644 index 000000000..5b510bad0 --- /dev/null +++ b/ethernet-view/src/components/OrdersContainer/useSendOrder.ts @@ -0,0 +1,9 @@ +import { Order, useWsHandler } from "common"; + +export function useSendOrder() { + const handler = useWsHandler(); + + return (order: Order) => { + handler.post("order/send", order); + }; +} diff --git a/ethernet-view/src/components/ProgressBar/ProgressBar.module.scss b/ethernet-view/src/components/ProgressBar/ProgressBar.module.scss new file mode 100644 index 000000000..ec3333de9 --- /dev/null +++ b/ethernet-view/src/components/ProgressBar/ProgressBar.module.scss @@ -0,0 +1,15 @@ +@use "src/styles/colors" as colors; + +.progressBar { + height: 1rem; + display: flex; + align-items: stretch; + border-radius: 0.25rem; + overflow: hidden; + background-color: colors.getThemeColor("surface-variant"); +} + +.fill { + background-color: colors.getThemeColor("success"); + transition: width 0.3s ease; +} diff --git a/ethernet-view/src/components/ProgressBar/ProgressBar.tsx b/ethernet-view/src/components/ProgressBar/ProgressBar.tsx new file mode 100644 index 000000000..4458d0e4b --- /dev/null +++ b/ethernet-view/src/components/ProgressBar/ProgressBar.tsx @@ -0,0 +1,16 @@ +import styles from "./ProgressBar.module.scss"; + +type Props = { + progress: number; // Between 0 and 100 +}; + +export const ProgressBar = ({ progress }: Props) => { + return ( +
+
+
+ ); +}; diff --git a/ethernet-view/src/components/ReceiveTable/BoardView/BoardView.module.scss b/ethernet-view/src/components/ReceiveTable/BoardView/BoardView.module.scss new file mode 100644 index 000000000..2050fd1e7 --- /dev/null +++ b/ethernet-view/src/components/ReceiveTable/BoardView/BoardView.module.scss @@ -0,0 +1,19 @@ +@use "src/styles/styles"; +@use "src/styles/colors" as colors; + +.boardView { + display: flex; + flex-direction: column; + border-radius: 0.5rem; + overflow: hidden; + overflow-x: auto; + flex: 0 0 auto; + border: 1px solid var(--main-color); + color: var(--light-main-color); + background-color: colors.getThemeColor("surface-variant"); + @include styles.shadow; +} + +:global([data-theme="dark"]) .boardView { + background-color: colors.getThemeColor("surface"); +} diff --git a/ethernet-view/src/components/ReceiveTable/BoardView/BoardView.tsx b/ethernet-view/src/components/ReceiveTable/BoardView/BoardView.tsx new file mode 100644 index 000000000..3da5fa922 --- /dev/null +++ b/ethernet-view/src/components/ReceiveTable/BoardView/BoardView.tsx @@ -0,0 +1,32 @@ +import { Board } from "common"; +import styles from "./BoardView.module.scss"; +import { PacketView } from "./PacketView/PacketView"; +import { useState } from "react"; +import { Header } from "./Header/Header"; + +type Props = { + board: Board; +}; + +export const BoardView = ({ board }: Props) => { + const [open, setOpen] = useState(false); + + return ( +
+
setOpen((prev) => !prev)} + >
+ {open && + board.packets.map((packet) => { + return ( + + ); + })} +
+ ); +}; diff --git a/ethernet-view/src/components/ReceiveTable/BoardView/Header/Header.module.scss b/ethernet-view/src/components/ReceiveTable/BoardView/Header/Header.module.scss new file mode 100644 index 000000000..a3bf739a3 --- /dev/null +++ b/ethernet-view/src/components/ReceiveTable/BoardView/Header/Header.module.scss @@ -0,0 +1,12 @@ +@use "src/styles/colors" as colors; + +.header { + display: flex; + align-items: center; + padding: 0.5rem; + gap: 0.5rem; + font-weight: bold; + background-color: var(--light-bg-color); + cursor: pointer; +} + diff --git a/ethernet-view/src/components/ReceiveTable/BoardView/Header/Header.tsx b/ethernet-view/src/components/ReceiveTable/BoardView/Header/Header.tsx new file mode 100644 index 000000000..6b2cc93bc --- /dev/null +++ b/ethernet-view/src/components/ReceiveTable/BoardView/Header/Header.tsx @@ -0,0 +1,20 @@ +import { Caret } from "components/Caret/Caret"; +import styles from "./Header.module.scss"; + +type Props = { + name: string; + open: boolean; + onClick: () => void; +}; + +export const Header = ({ name, open, onClick }: Props) => { + return ( +
+ + {name} +
+ ); +}; diff --git a/ethernet-view/src/components/ReceiveTable/BoardView/PacketView/HexValue/HexValue.module.scss b/ethernet-view/src/components/ReceiveTable/BoardView/PacketView/HexValue/HexValue.module.scss new file mode 100644 index 000000000..186aa21da --- /dev/null +++ b/ethernet-view/src/components/ReceiveTable/BoardView/PacketView/HexValue/HexValue.module.scss @@ -0,0 +1,8 @@ +.hexValue { + display: inline-flex; + flex-wrap: wrap; + column-gap: 1rem; + row-gap: 0.5rem; + font-family: Consolas; + font-weight: bold; +} diff --git a/ethernet-view/src/components/ReceiveTable/BoardView/PacketView/HexValue/HexValue.tsx b/ethernet-view/src/components/ReceiveTable/BoardView/PacketView/HexValue/HexValue.tsx new file mode 100644 index 000000000..4ce2d8152 --- /dev/null +++ b/ethernet-view/src/components/ReceiveTable/BoardView/PacketView/HexValue/HexValue.tsx @@ -0,0 +1,39 @@ +import styles from "./HexValue.module.scss"; +import { useMemo } from "react"; + +function getByteArr(hex: string): string[] { + const byteArr = [] as string[]; + + for (let i = 0; i < hex.length - 1; i += 2) { + byteArr.push(hex[i] + hex[i + 1]); + } + + if (hex.length % 2 != 0) { + byteArr.push(hex[hex.length - 1]); + } + + return byteArr; +} + +type Props = { + hex: string; +}; + +export const HexValue = ({ hex }: Props) => { + const byteArr = useMemo(() => getByteArr(hex.toUpperCase()), [hex]); + + return ( +
+ {byteArr.map((byte, index) => { + return ( + + {byte} + + ); + })} +
+ ); +}; diff --git a/ethernet-view/src/components/ReceiveTable/BoardView/PacketView/MeasurementView/MeasurementView.module.scss b/ethernet-view/src/components/ReceiveTable/BoardView/PacketView/MeasurementView/MeasurementView.module.scss new file mode 100644 index 000000000..da95e0b1f --- /dev/null +++ b/ethernet-view/src/components/ReceiveTable/BoardView/PacketView/MeasurementView/MeasurementView.module.scss @@ -0,0 +1,49 @@ +@use "src/styles/colors" as colors; + +// .measurementView { +// display: flex; +// align-items: center; +// gap: 2rem; +// padding: 0.4rem 0; +// overflow: hidden; +// } + +// .measurementView:not(:last-child) { +// border-bottom: 1px solid var(--border-color); +// } + +.name, +.value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.name { + grid-column: 1; + color: colors.getThemeColor("text-secondary"); +} + +.show_last { + grid-column: 2; +} + +.value { + grid-column: 3; + text-align: right; + color: colors.getThemeColor("secondary"); + font-family: Consolas; + font-size: 1.2rem; +} + +.units { + grid-column: 4; + text-align: center; + color: colors.getThemeColor("text-tertiary"); +} + +.type { + grid-column: 5; + text-align: center; + color: colors.getThemeColor("text-tertiary"); +} diff --git a/ethernet-view/src/components/ReceiveTable/BoardView/PacketView/MeasurementView/MeasurementView.tsx b/ethernet-view/src/components/ReceiveTable/BoardView/PacketView/MeasurementView/MeasurementView.tsx new file mode 100644 index 000000000..26c7b2566 --- /dev/null +++ b/ethernet-view/src/components/ReceiveTable/BoardView/PacketView/MeasurementView/MeasurementView.tsx @@ -0,0 +1,90 @@ +import styles from './MeasurementView.module.scss'; +import { + Measurement, + isNumericMeasurement, + useMeasurementsStore, +} from 'common'; +import { useUpdater } from './useUpdater'; +import { FormEvent } from 'react'; + +type Props = { + measurement: Measurement; +}; + +export const MeasurementView = ({ measurement }: Props) => { + const setShowMeasurementLatest = useMeasurementsStore( + (state) => (showLatest: boolean) => + state.showMeasurementLatest(measurement.id, showLatest) + ); + const isNumeric = isNumericMeasurement(measurement); + + const { valueRef } = useUpdater( + measurement.id, + isNumeric + ? measurement.value.showLatest + ? measurement.value.last.toFixed(3) + : measurement.value.average.toFixed(3) + : measurement.value.toString() + ); + + const onLatestValueChange = (event: FormEvent) => { + setShowMeasurementLatest(event.currentTarget.checked); + }; + + const setLog = (log: boolean) => { + useMeasurementsStore.setState(state => { + const measurements = { ...state.measurements }; + measurements[measurement.id] = { ...measurements[measurement.id], log }; + return { ...state, measurements }; + }); + }; + + const logChecked = useMeasurementsStore(state => state.measurements[measurement.id]?.log !== false); + + const showLatest = useMeasurementsStore(state => { + const meas = state.measurements[measurement.id]; + return isNumeric && meas && typeof meas.value === 'object' && 'showLatest' in meas.value + ? meas.value.showLatest + : false; + }); + + return ( + <> + + setLog(e.currentTarget.checked)} + /> + + {measurement.name} + + + {isNumeric && ( + <> + + + + + {measurement.units} + {measurement.type} + + )} + {!isNumeric && ( + <> + + {measurement.type} + + )} + + ); +}; diff --git a/ethernet-view/src/components/ReceiveTable/BoardView/PacketView/MeasurementView/useUpdater.ts b/ethernet-view/src/components/ReceiveTable/BoardView/PacketView/MeasurementView/useUpdater.ts new file mode 100644 index 000000000..fbf23730f --- /dev/null +++ b/ethernet-view/src/components/ReceiveTable/BoardView/PacketView/MeasurementView/useUpdater.ts @@ -0,0 +1,25 @@ +import { useLayoutEffect, useRef, useContext } from "react"; +import { TableContext } from "components/ReceiveTable/TableUpdater"; + +//TODO: receive just one id prop, or, even better, a getValue function (make it decoupled from store basically) +export function useUpdater(id: string, initialValue: string) { + const updater = useContext(TableContext); + const valueRef = useRef(null); + + useLayoutEffect(() => { + const valueNode = document.createTextNode(initialValue); + valueRef.current?.appendChild(valueNode); + + updater.addMeasurement({ + id: id, + value: valueNode, + }); + + return () => { + updater.removeMeasurement(id); + valueRef.current!.removeChild(valueNode); + }; + }, []); + + return { valueRef }; +} diff --git a/ethernet-view/src/components/ReceiveTable/BoardView/PacketView/PacketView.module.scss b/ethernet-view/src/components/ReceiveTable/BoardView/PacketView/PacketView.module.scss new file mode 100644 index 000000000..c4ca130bd --- /dev/null +++ b/ethernet-view/src/components/ReceiveTable/BoardView/PacketView/PacketView.module.scss @@ -0,0 +1,46 @@ +@use "src/styles/styles"; +@use "src/styles/colors" as colors; + +.packetView { + display: flex; + flex-direction: column; + color: colors.getThemeColor("text-primary"); + --border-color: #{colors.getThemeColor("border")}; + --border-width: 1px; +} + +.data { + display: flex; + align-items: stretch; + border-top: var(--border-width) solid var(--border-color); + border-bottom: var(--border-width) solid var(--border-color); + background-color: colors.getThemeColor("surface"); + @include styles.shadow; +} + +.data > * { + flex: 1 1 0; + text-align: center; + padding: 0.5rem; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.data > *:not(:first-child) { + border-left: var(--border-width) solid var(--border-color); +} + +.measurements { + padding: 0.5rem 1rem; + display: grid; + grid-template-columns: minmax(0, 15rem) min-content min-content min-content; + align-items: center; + gap: 1rem; +} + +.count, +.cycleTime { + font-size: 1.15rem; + font-family: Consolas; +} diff --git a/ethernet-view/src/components/ReceiveTable/BoardView/PacketView/PacketView.tsx b/ethernet-view/src/components/ReceiveTable/BoardView/PacketView/PacketView.tsx new file mode 100644 index 000000000..e5475357b --- /dev/null +++ b/ethernet-view/src/components/ReceiveTable/BoardView/PacketView/PacketView.tsx @@ -0,0 +1,47 @@ +import { Packet } from "common"; +import styles from "./PacketView.module.scss"; +import { MeasurementView } from "./MeasurementView/MeasurementView"; +import { memo } from "react"; +import { useUpdater } from "./useUpdater"; +import { useColumnsStore } from "store/columnsStore"; + +type Props = { + packet: Packet; +}; + +export const PacketView = memo(({ packet }: Props) => { + const columnSizes = useColumnsStore((state) => state.columnSizes); + + const { countRef, cycleTimeRef } = useUpdater(packet); + + return ( +
+
+
{packet.id}
+
{packet.name}
+
+
+
+ {Object.keys(packet.measurements).length > 0 && ( +
+ {packet.measurements.map((measurement) => { + return ( + + ); + })} +
+ )} +
+ ); +}); diff --git a/ethernet-view/src/components/ReceiveTable/BoardView/PacketView/useUpdater.ts b/ethernet-view/src/components/ReceiveTable/BoardView/PacketView/useUpdater.ts new file mode 100644 index 000000000..0d75bd08d --- /dev/null +++ b/ethernet-view/src/components/ReceiveTable/BoardView/PacketView/useUpdater.ts @@ -0,0 +1,34 @@ +import { Packet } from "common"; +import { useLayoutEffect, useRef, useContext } from "react"; +import { TableContext } from "../../TableUpdater"; + +export function useUpdater(packet: Packet) { + const updater = useContext(TableContext); + + const countRef = useRef(null); + const cycleTimeRef = useRef(null); + + useLayoutEffect(() => { + const countNode = document.createTextNode(packet.count.toFixed(2)); + const cycleTimeNode = document.createTextNode( + packet.cycleTime.toFixed(0) + ); + + countRef.current!.appendChild(countNode); + cycleTimeRef.current!.appendChild(cycleTimeNode); + + updater.addPacket(packet.id, { + count: countNode, + cycleTime: cycleTimeNode, + }); + + return () => { + updater.removePacket(packet.id); + + countRef.current!.removeChild(countNode); + cycleTimeRef.current!.removeChild(cycleTimeNode); + }; + }, [packet]); + + return { countRef, cycleTimeRef }; +} diff --git a/ethernet-view/src/components/ReceiveTable/Header/Header.module.scss b/ethernet-view/src/components/ReceiveTable/Header/Header.module.scss new file mode 100644 index 000000000..ef4f2e3a5 --- /dev/null +++ b/ethernet-view/src/components/ReceiveTable/Header/Header.module.scss @@ -0,0 +1,23 @@ +@use "src/styles/styles"; + +.header { + position: sticky; + top: 0; + display: flex; + align-items: stretch; + padding: 0 0.5rem; + border-radius: 0.5rem; + border: 1px solid var(--main-color); + background-color: var(--bg-color); + color: var(--main-color); + @include styles.shadow; +} + +.cell { + padding: 0.5rem 0; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: bold; +} diff --git a/ethernet-view/src/components/ReceiveTable/Header/Header.tsx b/ethernet-view/src/components/ReceiveTable/Header/Header.tsx new file mode 100644 index 000000000..efb2045bd --- /dev/null +++ b/ethernet-view/src/components/ReceiveTable/Header/Header.tsx @@ -0,0 +1,90 @@ +import styles from "./Header.module.scss"; +import { useSplit } from "hooks/useSplit/useSplit"; +import { Orientation } from "hooks/useSplit/Orientation"; +import { Separator } from "./Separator/Separator"; +import { useEffect } from "react"; +import { useColumnsStore } from "store/columnsStore"; + +type Props = { + items: string[]; +}; + +const MINIMUM_ITEM_SIZE = 0.05; + +export const Header = ({ items }: Props) => { + const columnSizes = useColumnsStore((state) => state.columnSizes); + const setColumnSizes = useColumnsStore((state) => state.setColumnSizes); + + const [splitElements, handleMouseDown] = useSplit( + new Array(items.length).fill(MINIMUM_ITEM_SIZE), + Orientation.HORIZONTAL + ); + + useEffect(() => { + setColumnSizes(splitElements.map((element) => `${element.length * 100}%`)); + }, [splitElements]); + + return ( +
+ {items.map((item, index) => { + if (index < items.length - 1) { + return ( + handleMouseDown(index, ev)} + /> + ); + } + + return ( + + ); + })} +
+ ); +}; + +const CellWithSeparator = ({ + label, + flexBasis, + onMouseDown, +}: { + label: string; + flexBasis: string; + onMouseDown: (ev: React.MouseEvent) => void; +}) => { + return ( + <> +
+ {label} +
+ onMouseDown(ev)}> + + ); +}; + +const CellWithoutSeparator = ({ + label, + flexBasis, +}: { + label: string; + flexBasis: string; +}) => { + return ( +
+ {label} +
+ ); +}; diff --git a/ethernet-view/src/components/ReceiveTable/Header/Separator/Separator.module.scss b/ethernet-view/src/components/ReceiveTable/Header/Separator/Separator.module.scss new file mode 100644 index 000000000..5a1df27d4 --- /dev/null +++ b/ethernet-view/src/components/ReceiveTable/Header/Separator/Separator.module.scss @@ -0,0 +1,11 @@ +.separator { + padding: 0 0.6rem; + display: flex; + justify-content: center; + cursor: e-resize; +} + +.line { + width: 1px; + background-color: var(--main-color); +} diff --git a/ethernet-view/src/components/ReceiveTable/Header/Separator/Separator.tsx b/ethernet-view/src/components/ReceiveTable/Header/Separator/Separator.tsx new file mode 100644 index 000000000..f39cfd555 --- /dev/null +++ b/ethernet-view/src/components/ReceiveTable/Header/Separator/Separator.tsx @@ -0,0 +1,17 @@ +import styles from "./Separator.module.scss"; +import { MouseEvent } from "react"; + +type Props = { + onMouseDown: (ev: MouseEvent) => void; +}; + +export const Separator = ({ onMouseDown }: Props) => { + return ( +
+
+
+ ); +}; diff --git a/ethernet-view/src/components/ReceiveTable/ReceiveTable.module.scss b/ethernet-view/src/components/ReceiveTable/ReceiveTable.module.scss new file mode 100644 index 000000000..cb97d7809 --- /dev/null +++ b/ethernet-view/src/components/ReceiveTable/ReceiveTable.module.scss @@ -0,0 +1,30 @@ +@use "src/styles/colors" as colors; + +.newReceiveTable { + --main-color: #{colors.getThemeColor("primary")}; + --bg-color: #{colors.getColor("primary", 85)}; + --light-main-color: #{colors.getColor("primary", 65)}; + --light-bg-color: #{colors.getColor("primary", 95)}; + + display: flex; + flex-direction: column; + min-height: 0; + border-radius: 0.5rem; // Para que la tabla no se veo en las esquinas superiores debido al hueco que deja el border-radius del header + flex: 1 1 0; +} + +:global([data-theme="dark"]) .newReceiveTable { + --main-color: #{colors.getThemeColor("text-primary")}; + --bg-color: #{colors.getColor("neutral", 20)}; + --light-main-color: #{colors.getThemeColor("text-secondary")}; + --light-bg-color: #{colors.getThemeColor("surface-variant")}; +} + +.boards { + padding-top: 1rem; + display: flex; + flex-direction: column; + flex-grow: 1; + overflow-y: auto; + gap: 1rem; +} diff --git a/ethernet-view/src/components/ReceiveTable/ReceiveTable.tsx b/ethernet-view/src/components/ReceiveTable/ReceiveTable.tsx new file mode 100644 index 000000000..f5cb00aeb --- /dev/null +++ b/ethernet-view/src/components/ReceiveTable/ReceiveTable.tsx @@ -0,0 +1,68 @@ +import styles from "./ReceiveTable.module.scss"; +import { BoardView } from "./BoardView/BoardView"; +import { Header } from "./Header/Header"; +import { TableUpdater } from "./TableUpdater"; +import { Board, useMeasurementsStore} from "common"; + +type Props = { + boards: Board[]; +}; + +export const ReceiveTable = ({ boards }: Props) => { + const handleLogAll = (log: boolean) => { + useMeasurementsStore.getState().setLogAll(log); + }; + + const handleShowAllLatest = (showLatest: boolean) => { + useMeasurementsStore.getState().setShowAllLatest(showLatest); + }; + return ( + +
+
+
+ + +
+
+ + +
+
+
+
+ {boards + .filter((item) => item.packets.length > 0) + .map((board) => { + return ( + + ); + })} +
+
+
+ ); +}; diff --git a/ethernet-view/src/components/ReceiveTable/TableUpdater.tsx b/ethernet-view/src/components/ReceiveTable/TableUpdater.tsx new file mode 100644 index 000000000..e360745eb --- /dev/null +++ b/ethernet-view/src/components/ReceiveTable/TableUpdater.tsx @@ -0,0 +1,105 @@ +import { + getPacket, + isNumericMeasurement, + useGlobalTicker, + useMeasurementsStore, + usePodDataStore, +} from 'common'; +import { createContext, useRef } from 'react'; + +export type PacketElement = { + count: Text; + cycleTime: Text; +}; + +type MeasurementElement = { id: string; value: Text }; + +type Updater = { + addPacket: (id: number, element: PacketElement) => void; + removePacket: (id: number) => void; + addMeasurement: (element: MeasurementElement) => void; + removeMeasurement: (id: string) => void; +}; + +export const TableContext = createContext({ + addPacket() {}, + removePacket() {}, + addMeasurement() {}, + removeMeasurement() {}, +}); + +type Props = { + children?: React.ReactNode; +}; + +export const TableUpdater = ({ children }: Props) => { + const packetElements = useRef>({}); + const measurementElements = useRef([]); + + const podData = usePodDataStore((state) => state.podData); + const getMeasurement = useMeasurementsStore( + (state) => state.getMeasurement + ); + + useGlobalTicker(() => { + for (const id in packetElements.current) { + const packet = getPacket(podData, Number.parseInt(id)); + const element = packetElements.current[id]; + if (packet) { + element.count.nodeValue = packet.count.toFixed(0); + element.cycleTime.nodeValue = packet.cycleTime.toFixed(0); + } else { + console.warn(`packet ${id} not found`); + } + } + + for (const item of measurementElements.current) { + const measurement = getMeasurement(item.id); + if (!measurement) { + console.warn(`measurement ${item.id} not found`); + return; + } + const element = measurementElements.current.find( + (elem) => elem.id == item.id + ); + if (!element) { + console.warn(`element of measurement ${item.id} not found`); + return; + } + element.value.nodeValue = isNumericMeasurement(measurement) + ? measurement.value.showLatest + ? measurement.value.last.toFixed(3) + : measurement.value.average.toFixed(3) + : measurement.value.toString(); + } + }); + + const updater: Updater = { + addPacket: (id: number, element: PacketElement) => { + packetElements.current[id] = element; + }, + removePacket(id) { + delete packetElements.current[id]; + }, + addMeasurement: (element: MeasurementElement) => { + if ( + !measurementElements.current.find( + (item) => item.id == element.id + ) + ) { + measurementElements.current.push(element); + } + }, + removeMeasurement: (id: string) => { + measurementElements.current = measurementElements.current.filter( + (item) => item.id != id + ); + }, + }; + + return ( + + {children} + + ); +}; diff --git a/ethernet-view/src/components/SplashScreen/SplashScreen.module.scss b/ethernet-view/src/components/SplashScreen/SplashScreen.module.scss new file mode 100644 index 000000000..985ce37b6 --- /dev/null +++ b/ethernet-view/src/components/SplashScreen/SplashScreen.module.scss @@ -0,0 +1,18 @@ +@use "src/styles/colors" as colors; + +.loadingView { + flex: 1 1 0; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + gap: 2rem; + font-size: 6rem; + font-weight: 900; + color: colors.getThemeColor("secondary"); +} + +.monkey { + font-family: NotoColorEmoji; +} diff --git a/ethernet-view/src/components/SplashScreen/SplashScreen.tsx b/ethernet-view/src/components/SplashScreen/SplashScreen.tsx new file mode 100644 index 000000000..c40ef6101 --- /dev/null +++ b/ethernet-view/src/components/SplashScreen/SplashScreen.tsx @@ -0,0 +1,22 @@ +import styles from './SplashScreen.module.scss'; +import { animated, useSpring } from '@react-spring/web'; + +// TODO: change for common front SplashScreen +export const SplashScreen = () => { + const springs = useSpring({ + from: { fontSize: '0rem' }, + to: { fontSize: '16rem' }, + config: { + mass: 5, + }, + delay: 150, + }); + + return ( +
+ + 🐒 + +
+ ); +}; diff --git a/ethernet-view/src/components/ThemeToggle/ThemeToggle.module.scss b/ethernet-view/src/components/ThemeToggle/ThemeToggle.module.scss new file mode 100644 index 000000000..476b8d932 --- /dev/null +++ b/ethernet-view/src/components/ThemeToggle/ThemeToggle.module.scss @@ -0,0 +1,34 @@ +@use "../../styles/colors.scss" as colors; + +.themeToggle { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: 1px solid colors.getThemeColor("border"); + border-radius: 8px; + background-color: colors.getThemeColor("surface"); + color: colors.getThemeColor("text-primary"); + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background-color: colors.getThemeColor("surface-hover"); + border-color: colors.getThemeColor("border-hover"); + transform: translateY(-1px); + box-shadow: colors.getThemeColor("shadow-sm"); + } + + &:active { + transform: translateY(0); + } +} + +.icon { + font-size: 20px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; +} \ No newline at end of file diff --git a/ethernet-view/src/components/ThemeToggle/ThemeToggle.tsx b/ethernet-view/src/components/ThemeToggle/ThemeToggle.tsx new file mode 100644 index 000000000..61fecf92c --- /dev/null +++ b/ethernet-view/src/components/ThemeToggle/ThemeToggle.tsx @@ -0,0 +1,31 @@ +import { useEffect, useState } from "react"; +import styles from "./ThemeToggle.module.scss"; + +export function ThemeToggle() { + const [theme, setTheme] = useState<"light" | "dark">(() => { + const savedTheme = localStorage.getItem("theme"); + return (savedTheme as "light" | "dark") || "light"; + }); + + useEffect(() => { + document.documentElement.setAttribute("data-theme", theme); + localStorage.setItem("theme", theme); + }, [theme]); + + const toggleTheme = () => { + setTheme((prev) => (prev === "light" ? "dark" : "light")); + }; + + return ( + + ); +} \ No newline at end of file diff --git a/ethernet-view/src/hooks/useBounds.ts b/ethernet-view/src/hooks/useBounds.ts new file mode 100644 index 000000000..d2d20dedb --- /dev/null +++ b/ethernet-view/src/hooks/useBounds.ts @@ -0,0 +1,39 @@ +import { useLayoutEffect, useRef, useState } from "react"; + +export function useBounds() { + const ref = useRef(null); + const [rect, setRect] = useState({ + x: 0, + y: 0, + height: 0, + width: 0, + bottom: 0, + left: 0, + right: 0, + top: 0, + toJSON: () => {}, + }); + const resizeObserverRef = useRef(); + + useLayoutEffect(() => { + setRect((prevRect) => { + if (ref.current) { + return ref.current.getBoundingClientRect(); + } else { + return { ...prevRect }; + } + }); + + resizeObserverRef.current = new ResizeObserver((entries) => { + setRect(entries[0].target.getBoundingClientRect()); + }); + + resizeObserverRef.current.observe(ref.current!); + + return () => { + resizeObserverRef.current?.disconnect(); + }; + }, []); + + return [ref, rect] as const; +} diff --git a/ethernet-view/src/hooks/useInterval.ts b/ethernet-view/src/hooks/useInterval.ts new file mode 100644 index 000000000..fe85cf5bf --- /dev/null +++ b/ethernet-view/src/hooks/useInterval.ts @@ -0,0 +1,18 @@ +import { useRef, useEffect } from "react"; + +export function useInterval(callback: () => void, delay: number) { + const savedCallback = useRef<() => void>(); + + useEffect(() => { + savedCallback.current = callback; + }); + + useEffect(() => { + function tick() { + savedCallback.current!(); + } + + let id = setInterval(tick, delay); + return () => clearInterval(id); + }, [delay]); +} diff --git a/ethernet-view/src/hooks/useSplit/InitialState.ts b/ethernet-view/src/hooks/useSplit/InitialState.ts new file mode 100644 index 000000000..33fa5c276 --- /dev/null +++ b/ethernet-view/src/hooks/useSplit/InitialState.ts @@ -0,0 +1,27 @@ +import { Orientation } from "./Orientation"; +import { Size, SplitElement } from "./useSplit"; + +export class InitialState { + public readonly initialMousePos: [number, number]; + public readonly initialElements: SplitElement[]; + public readonly direction: Orientation; + public readonly separatorIndex: number; + public readonly separatorSize: Size; + public readonly wrapperSize: Size; + + constructor( + mousePos: [number, number], + elements: SplitElement[], + direction: Orientation, + separatorIndex: number, + separatorSize: Size, + wrapperSize: Size + ) { + this.initialMousePos = mousePos; + this.initialElements = elements; + this.direction = direction; + this.separatorIndex = separatorIndex; + this.separatorSize = separatorSize; + this.wrapperSize = wrapperSize; + } +} diff --git a/ethernet-view/src/hooks/useSplit/Orientation.ts b/ethernet-view/src/hooks/useSplit/Orientation.ts new file mode 100644 index 000000000..a9f887111 --- /dev/null +++ b/ethernet-view/src/hooks/useSplit/Orientation.ts @@ -0,0 +1,4 @@ +export enum Orientation { + VERTICAL, + HORIZONTAL, +} diff --git a/ethernet-view/src/hooks/useSplit/SeparatorEventHandler.ts b/ethernet-view/src/hooks/useSplit/SeparatorEventHandler.ts new file mode 100644 index 000000000..4caf3ca57 --- /dev/null +++ b/ethernet-view/src/hooks/useSplit/SeparatorEventHandler.ts @@ -0,0 +1,23 @@ +export class SeparatorEventHandler { + public onMouseDown?: (ev: React.MouseEvent, separatorIndex: number) => void; + public onMove?: (clientX: number, clientY: number) => void; + public onMouseUp?: () => void; + + public handleMouseDown = (separatorIndex: number, ev: React.MouseEvent) => { + ev.preventDefault(); + this.onMouseDown?.(ev, separatorIndex); + document.addEventListener("mousemove", this.handleMouseMove); + document.addEventListener("mouseup", this.handleMouseUp); + }; + + private handleMouseMove = (ev: MouseEvent) => { + ev.preventDefault(); + this.onMove?.(ev.clientX, ev.clientY); + }; + + private handleMouseUp = (ev: MouseEvent) => { + ev.preventDefault(); + document.removeEventListener("mousemove", this.handleMouseMove); + document.removeEventListener("mouseup", this.handleMouseUp); + }; +} diff --git a/ethernet-view/src/hooks/useSplit/useSplit.ts b/ethernet-view/src/hooks/useSplit/useSplit.ts new file mode 100644 index 000000000..059bcf940 --- /dev/null +++ b/ethernet-view/src/hooks/useSplit/useSplit.ts @@ -0,0 +1,271 @@ +import { useState, useEffect, useMemo } from "react"; +import { SeparatorEventHandler } from "./SeparatorEventHandler"; +import { Orientation } from "./Orientation"; +import { InitialState as InitialState } from "./InitialState"; + +export type Size = { + width: number; + height: number; +}; + +export type SplitElement = { + length: number; + minLength: number; +}; + +/** + * The `useSplit` function in TypeScript is used to manage resizable elements with specified initial + * lengths and minimum lengths in a given direction. + * @param {number[] | undefined} initialLengths - The `initialLengths` parameter is an array of numbers + * representing the initial lengths of the elements to be split. It can be either `undefined` or an + * array of numbers. If it is `undefined`, if its length is not equal to the length of the + * `minLengths` array or if its sum is not equal to 1, the `initialLengths` will be set proportionally equal. + * @param {number[]} minLengths - The `minLengths` parameter in the `useSplit` function represents an + * array of minimum lengths for each split element. These minimum lengths determine the smallest size + * each element can be resized to. If a user tries to resize an element below its minimum length, the + * element will be collapsed. + * @param {Orientation} direction - The `direction` parameter in the `useSplit` function is used to + * determine the orientation of the split elements. It is of type `Orientation`, which is likely an + * enum or type that specifies whether the split should be horizontal or vertical. This helps in + * calculating the resizing of elements based on the direction + * @returns The `useSplit` function returns an array containing the `elements` state and the + * `handleMouseDown` function from the `separatorEventHandler`. + */ +export function useSplit(minLengths: number[], direction: Orientation, initialLengths?: number[]) { + + if(!initialLengths || initialLengths.length != minLengths.length || initialLengths.reduce((prev, curr) => prev + curr, 0) != 1) { + initialLengths = minLengths.map(() => 1 / minLengths.length); + } + + const [elements, setElements] = useState( + initialLengths.map((length, index) => ({ + length, + minLength: minLengths[index], + })) + ); + + const separatorEventHandler = useMemo( + () => new SeparatorEventHandler(), + [] + ); + + useEffect(() => { + separatorEventHandler.onMouseDown = (ev, sepIndex) => { + const separator = ev.currentTarget as HTMLElement; + const wrapper = separator.parentElement!; + + const initialState = new InitialState( + [ev.clientX, ev.clientY], + [...elements], + direction, + sepIndex, + getSize(separator), + getSize(wrapper) + ); + + separatorEventHandler.onMove = (clientX, clientY) => { + const normDistace = getNormDistance( + clientX, + clientY, + direction, + initialState + ); + + setElements(() => { + const newElements = getResizedElements( + initialState.initialElements, + normDistace, + initialState.separatorIndex + ); + + return newElements; + }); + }; + }; + + separatorEventHandler.onMouseUp = () => { + separatorEventHandler.onMove = () => {}; + }; + }, [elements]); + + return [elements, separatorEventHandler.handleMouseDown] as const; +} + +function getResizedElements( + elements: SplitElement[], + normDistance: number, + sepIndex: number +) { + const direction = Math.sign(normDistance) as 1 | 0 | -1; + + const { affected, affectedIndex, unaffected, unaffectedIndex } = + getAffectedAndUnaffectedElements(elements, direction, sepIndex); + + const shrinkedElements = substractDisplacement(affected, normDistance); + + const mainElementIndex = direction == 1 ? sepIndex : sepIndex + 1; + + const newMainElement = getNewMainElement(elements[mainElementIndex], [ + ...shrinkedElements, + ...unaffected, + ]); + + return getFinalElements( + newMainElement, + mainElementIndex, + shrinkedElements, + affectedIndex, + unaffected, + unaffectedIndex, + direction + ); +} + +function getFinalElements( + newMainElement: SplitElement, + newMainElementIndex: number, + shrinkedElements: SplitElement[], + shrinkedElementsIndex: number, + unaffectedElements: SplitElement[], + unaffectedElementsIndex: number, + direction: 1 | 0 | -1 +) { + return direction == 1 || direction == 0 + ? Object.assign( + [] as SplitElement[], + { + [unaffectedElementsIndex]: unaffectedElements, + }, + { + [newMainElementIndex]: newMainElement, + }, + { + [shrinkedElementsIndex]: shrinkedElements, + } + ).flat() + : Object.assign( + [] as SplitElement[], + { + [shrinkedElementsIndex]: shrinkedElements, + }, + { + [newMainElementIndex]: newMainElement, + }, + + { + [unaffectedElementsIndex]: unaffectedElements, + } + ).flat(); +} + +function getAffectedAndUnaffectedElements( + elements: SplitElement[], + direction: 1 | 0 | -1, + separatorIndex: number +): { + affected: SplitElement[]; + affectedIndex: number; + unaffected: SplitElement[]; + unaffectedIndex: number; +} { + if (direction == 1) { + return { + affected: elements.slice(separatorIndex + 1), + affectedIndex: separatorIndex + 1, + unaffected: elements.slice(0, separatorIndex), + unaffectedIndex: 0, + }; + } else if (direction == -1) { + return { + affected: elements.slice(0, separatorIndex + 1), + affectedIndex: 0, + unaffected: elements.slice(separatorIndex + 2), + unaffectedIndex: separatorIndex + 2, + }; + } else { + return { + affected: [], + affectedIndex: 1, + unaffected: elements, + unaffectedIndex: 0, + }; + } +} + +function getNewMainElement( + mainElement: SplitElement, + elements: SplitElement[] +): SplitElement { + const mainElementLength = + 1 - + elements.reduce((prevValue, currentElement) => { + return prevValue + currentElement.length; + }, 0); + + return { + length: mainElementLength, + minLength: mainElement.minLength, + }; +} + +function substractDisplacement( + elements: SplitElement[], + distance: number +): SplitElement[] { + const orderedElements = + distance >= 0 ? [...elements] : [...elements].reverse(); + + let remaindingDistance = Math.abs(distance); + + for (let i = 0; i < orderedElements.length; i++) { + let newLength = orderedElements[i].length - remaindingDistance; + if(newLength < orderedElements[i].minLength) newLength = 0; + remaindingDistance = Math.max( + remaindingDistance - (orderedElements[i].length - newLength), + 0 + ); + + orderedElements[i] = { + length: newLength, + minLength: orderedElements[i].minLength, + }; + + if (remaindingDistance < 0.00001) break; + } + + return distance > 0 ? orderedElements : orderedElements.reverse(); +} + +function getNormDistance( + x: number, + y: number, + direction: Orientation, + state: InitialState +) { + const distance = + direction == Orientation.HORIZONTAL + ? x - state.initialMousePos[0] + : y - state.initialMousePos[1]; + + const wrapperLength = getLength(state.wrapperSize, state.direction); + + const wrapperLengthWithoutSeparators = + wrapperLength - + getLength(state.separatorSize, state.direction) * + (state.initialElements.length - 1); + + return distance / wrapperLengthWithoutSeparators; +} + +function getLength(size: Size, direction: Orientation): number { + if (direction == Orientation.HORIZONTAL) { + return size.width; + } else { + return size.height; + } +} + +function getSize(element: HTMLElement): Size { + const rect = element.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; +} diff --git a/ethernet-view/src/index.scss b/ethernet-view/src/index.scss new file mode 100644 index 000000000..6d74c522f --- /dev/null +++ b/ethernet-view/src/index.scss @@ -0,0 +1,22 @@ +@use "src/styles/styles"; + +body { + margin: 0; + padding: 0; + /*TODO: cambiar width y height para que si haces la ventana mas pequeña, los componentes se hacen */ + height: 100vh; + background-color: styles.$background-color; +} +* { + box-sizing: border-box; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; +} + +#root { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; +} diff --git a/ethernet-view/src/layouts/AppLayout/AppLayout.module.scss b/ethernet-view/src/layouts/AppLayout/AppLayout.module.scss new file mode 100644 index 000000000..7b0dd2f59 --- /dev/null +++ b/ethernet-view/src/layouts/AppLayout/AppLayout.module.scss @@ -0,0 +1,9 @@ +@use "../../styles/styles.scss"; + +.appLayout { + display: flex; + height: 100%; + width: 100%; + gap: styles.$normal-padding; + padding: styles.$normal-padding; +} \ No newline at end of file diff --git a/ethernet-view/src/layouts/AppLayout/AppLayout.tsx b/ethernet-view/src/layouts/AppLayout/AppLayout.tsx new file mode 100644 index 000000000..dfaf3717e --- /dev/null +++ b/ethernet-view/src/layouts/AppLayout/AppLayout.tsx @@ -0,0 +1,38 @@ +import styles from "./AppLayout.module.scss"; +import Testing from "assets/svg/testing.svg"; +import Logger from "assets/svg/logger.svg"; +import Camera from "assets/svg/camera.svg"; +import { Navbar } from "components/Navbar/Navbar"; +import { ReactNode } from "react"; + +interface Props { + children: ReactNode; + pageShown: string; + setPageShown: (page: string) => void; +} + +export const AppLayout = ({ children, pageShown, setPageShown }: Props) => { + return ( +
+ + {children} +
+ ); +}; diff --git a/ethernet-view/src/layouts/SplitLayout/Pane/Pane.module.scss b/ethernet-view/src/layouts/SplitLayout/Pane/Pane.module.scss new file mode 100644 index 000000000..8cd0af2ef --- /dev/null +++ b/ethernet-view/src/layouts/SplitLayout/Pane/Pane.module.scss @@ -0,0 +1,23 @@ +@use "src/styles/styles"; +@use "src/styles/colors" as colors; + +.wrapper { + min-width: 0; + min-height: 0; +} + +.collapsed { + padding: .6rem; + background-color: colors.getThemeColor("primary"); + border-radius: .8rem; +} + +.icon { + width: 2rem; + + img { + filter: invert(1); + width: 100%; + color: colors.getThemeColor("primary-text"); + } +} \ No newline at end of file diff --git a/ethernet-view/src/layouts/SplitLayout/Pane/Pane.tsx b/ethernet-view/src/layouts/SplitLayout/Pane/Pane.tsx new file mode 100644 index 000000000..8d7deb717 --- /dev/null +++ b/ethernet-view/src/layouts/SplitLayout/Pane/Pane.tsx @@ -0,0 +1,32 @@ +import styles from "layouts/SplitLayout/Pane/Pane.module.scss"; + +type Props = { + component: React.ReactNode; + normalizedLength: number; + collapsedIcon: string; +}; + +export const Pane = ({ component, normalizedLength, collapsedIcon }: Props) => { + + const isCollapsed = normalizedLength === 0; + + return ( +
+
+ collapsed +
+ +
+ {component} +
+ +
+ ); +}; diff --git a/ethernet-view/src/layouts/SplitLayout/Separator/Separator.module.scss b/ethernet-view/src/layouts/SplitLayout/Separator/Separator.module.scss new file mode 100644 index 000000000..dc0d0d853 --- /dev/null +++ b/ethernet-view/src/layouts/SplitLayout/Separator/Separator.module.scss @@ -0,0 +1,21 @@ +@use "src/styles/styles"; +@use "src/styles/colors" as colors; + +.wrapper { + flex: 0 1 0rem; + padding: 0.5rem; + .line { + width: 100%; + height: 100%; + padding: 1px; + border-radius: 1rem; + background-color: none; + transition: styles.$background-color-transition; + } + + &:hover { + .line { + background-color: colors.getThemeColor("border-hover"); + } + } +} diff --git a/ethernet-view/src/layouts/SplitLayout/Separator/Separator.tsx b/ethernet-view/src/layouts/SplitLayout/Separator/Separator.tsx new file mode 100644 index 000000000..69b798c25 --- /dev/null +++ b/ethernet-view/src/layouts/SplitLayout/Separator/Separator.tsx @@ -0,0 +1,27 @@ +import { Orientation } from "hooks/useSplit/Orientation"; +import styles from "layouts/SplitLayout/Separator/Separator.module.scss"; +import { MouseEvent } from "react"; + +type Props = { + orientation: Orientation; + onMouseDown: (ev: MouseEvent) => void; +}; + +export const Separator = ({ orientation, onMouseDown }: Props) => { + return ( +
{ + onMouseDown(ev); + }} + > +
+
+ ); +}; diff --git a/ethernet-view/src/layouts/SplitLayout/SplitLayout.module.scss b/ethernet-view/src/layouts/SplitLayout/SplitLayout.module.scss new file mode 100644 index 000000000..7d2ec976e --- /dev/null +++ b/ethernet-view/src/layouts/SplitLayout/SplitLayout.module.scss @@ -0,0 +1,7 @@ +.wrapper { + width: 100%; + height: 100%; + min-height: 0; + display: flex; + justify-content: space-between; +} diff --git a/ethernet-view/src/layouts/SplitLayout/SplitLayout.tsx b/ethernet-view/src/layouts/SplitLayout/SplitLayout.tsx new file mode 100644 index 000000000..1d17b24e2 --- /dev/null +++ b/ethernet-view/src/layouts/SplitLayout/SplitLayout.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import styles from "layouts/SplitLayout/SplitLayout.module.scss"; +import { useSplit } from "hooks/useSplit/useSplit"; +import { Pane } from "layouts/SplitLayout/Pane/Pane"; +import { Separator } from "layouts/SplitLayout/Separator/Separator"; +import { Orientation } from "hooks/useSplit/Orientation"; + +type Props = { + components: { + component: React.ReactNode; + collapsedIcon: string; + }[]; + orientation?: Orientation; + initialLengths?: number[]; +}; + +export const SplitLayout = ({ initialLengths, components, orientation = Orientation.HORIZONTAL }: Props) => { + + const minLengths = components.map(() => 0.05); + const [splitElements, onSeparatorMouseDown] = useSplit(minLengths, orientation, initialLengths); + + return ( +
+ {components.map((component, index) => { + return ( + + + {index < components.length - 1 && ( + + onSeparatorMouseDown(index, ev) + } + /> + )} + + ); + })} +
+ ); +}; diff --git a/ethernet-view/src/layouts/TabLayout/Header/Header.module.scss b/ethernet-view/src/layouts/TabLayout/Header/Header.module.scss new file mode 100644 index 000000000..590c94c07 --- /dev/null +++ b/ethernet-view/src/layouts/TabLayout/Header/Header.module.scss @@ -0,0 +1,19 @@ +@use "src/styles/styles"; + +.headerWrapper { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + gap: 1rem; + flex: 0 0 2.5rem; + overflow-x: clip; + + margin-bottom: 1.2rem; + .name { + display: flex; + align-items: center; + @include styles.title-text; + line-height: 95%; + } +} diff --git a/ethernet-view/src/layouts/TabLayout/Header/Header.tsx b/ethernet-view/src/layouts/TabLayout/Header/Header.tsx new file mode 100644 index 000000000..b8245ab0f --- /dev/null +++ b/ethernet-view/src/layouts/TabLayout/Header/Header.tsx @@ -0,0 +1,24 @@ +import styles from "./Header.module.scss"; +import { TabBar } from "./TabBar/TabBar"; +import { TabItem } from "layouts/TabLayout/TabItem"; + +type Props = { + tabs: TabItem[]; + visibleTab: TabItem; + onTabClick: (tab: TabItem) => void; +}; + +export const Header = ({ tabs, visibleTab, onTabClick }: Props) => { + return ( +
+
{visibleTab.name}
+ {tabs.length > 1 && ( + + )} +
+ ); +}; diff --git a/ethernet-view/src/layouts/TabLayout/Header/TabBar/Tab/Tab.module.scss b/ethernet-view/src/layouts/TabLayout/Header/TabBar/Tab/Tab.module.scss new file mode 100644 index 000000000..64e5e5f8e --- /dev/null +++ b/ethernet-view/src/layouts/TabLayout/Header/TabBar/Tab/Tab.module.scss @@ -0,0 +1,23 @@ +@use "src/styles/styles"; +@use "src/styles/colors" as colors; + +.wrapper { + display: grid; + grid-template: "icon title" / auto auto; + align-items: center; + gap: 0.5rem; + height: auto; + padding: 0.5rem; + //border: 2px solid styles.$alternate-text-color; + background-color: colors.getThemeColor("surface-variant"); + border-radius: 0.8rem; + cursor: pointer; +} + +.icon { + grid-area: icon; + + align-self: center; + justify-self: center; + font-size: 1.5rem; +} diff --git a/ethernet-view/src/layouts/TabLayout/Header/TabBar/Tab/Tab.tsx b/ethernet-view/src/layouts/TabLayout/Header/TabBar/Tab/Tab.tsx new file mode 100644 index 000000000..32b3ceff8 --- /dev/null +++ b/ethernet-view/src/layouts/TabLayout/Header/TabBar/Tab/Tab.tsx @@ -0,0 +1,19 @@ +import styles from "./Tab.module.scss"; + +type Props = { + name: string; + className?: string; + icon?: React.ReactNode; + onClick: () => void; +}; +export const Tab = ({ name, icon, onClick, className = "" }: Props) => { + return ( +
+ {icon} +
{name}
+
+ ); +}; diff --git a/ethernet-view/src/layouts/TabLayout/Header/TabBar/TabBar.module.scss b/ethernet-view/src/layouts/TabLayout/Header/TabBar/TabBar.module.scss new file mode 100644 index 000000000..9141c4373 --- /dev/null +++ b/ethernet-view/src/layouts/TabLayout/Header/TabBar/TabBar.module.scss @@ -0,0 +1,5 @@ +.wrapper { + display: flex; + flex-direction: row; + gap: 0.5rem; +} diff --git a/ethernet-view/src/layouts/TabLayout/Header/TabBar/TabBar.tsx b/ethernet-view/src/layouts/TabLayout/Header/TabBar/TabBar.tsx new file mode 100644 index 000000000..65e1bae37 --- /dev/null +++ b/ethernet-view/src/layouts/TabLayout/Header/TabBar/TabBar.tsx @@ -0,0 +1,27 @@ +import styles from "./TabBar.module.scss"; +import { Tab } from "./Tab/Tab"; +import { TabItem } from "layouts/TabLayout/TabItem"; + +type Props = { + tabs: TabItem[]; + onTabClick: (tab: TabItem) => void; + visibleTabId: string; +}; + +export const TabBar = ({ tabs, onTabClick, visibleTabId }: Props) => { + return ( +
+ {tabs.map((tab) => { + return ( + onTabClick(tab)} + > + ); + })} +
+ ); +}; diff --git a/ethernet-view/src/layouts/TabLayout/TabItem.ts b/ethernet-view/src/layouts/TabLayout/TabItem.ts new file mode 100644 index 000000000..aa3a5643a --- /dev/null +++ b/ethernet-view/src/layouts/TabLayout/TabItem.ts @@ -0,0 +1,7 @@ +export type TabItem = { + id: string; + name: string; + //FIXME: cambiar a obligatorio cuando arregle el bug de aria-hidden + icon?: React.ReactNode; + component: React.ReactNode; +}; diff --git a/ethernet-view/src/layouts/TabLayout/TabLayout.module.scss b/ethernet-view/src/layouts/TabLayout/TabLayout.module.scss new file mode 100644 index 000000000..7adc57f6a --- /dev/null +++ b/ethernet-view/src/layouts/TabLayout/TabLayout.module.scss @@ -0,0 +1,21 @@ +@use "src/styles/styles"; + +.body { + width: 100%; + min-width: 0; + min-height: 0; + flex-grow: 1; +} + +.componentWrapper { + width: 100%; + height: 100%; + overflow: hidden; +} + +.visibilityWrapper { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; +} diff --git a/ethernet-view/src/layouts/TabLayout/TabLayout.tsx b/ethernet-view/src/layouts/TabLayout/TabLayout.tsx new file mode 100644 index 000000000..395915439 --- /dev/null +++ b/ethernet-view/src/layouts/TabLayout/TabLayout.tsx @@ -0,0 +1,46 @@ +import styles from "layouts/TabLayout/TabLayout.module.scss"; +import { TabItem } from "layouts/TabLayout/TabItem"; +import { Header } from "layouts/TabLayout/Header/Header"; +import { Island } from "components/Island/Island"; +import { useState } from "react"; +type Props = { + tabs: TabItem[]; +}; + +export const TabLayout = ({ tabs }: Props) => { + const [visibleTab, setVisibleTab] = useState(tabs[0]); + + function onTabClick(tab: TabItem) { + setVisibleTab(tab); + } + + return ( + +
+
+
+ {tabs.map((tab) => { + return ( +
+ {tab.component} +
+ ); + })} +
+
+ + ); +}; diff --git a/ethernet-view/src/main.tsx b/ethernet-view/src/main.tsx new file mode 100644 index 000000000..c0b448232 --- /dev/null +++ b/ethernet-view/src/main.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import "common/dist/style.css"; +import "styles/fonts.scss"; +import "./index.scss"; +import { ConfigProvider, GlobalTicker } from "common"; +import App from "App"; + +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + + + + + + +); \ No newline at end of file diff --git a/ethernet-view/src/pages/CamerasPage/CamerasPage.module.scss b/ethernet-view/src/pages/CamerasPage/CamerasPage.module.scss new file mode 100644 index 000000000..d679f0475 --- /dev/null +++ b/ethernet-view/src/pages/CamerasPage/CamerasPage.module.scss @@ -0,0 +1,23 @@ +.camerasPage { + display: flex; + gap: 1rem; +} + +.camerasRow { + display: flex; + flex: 1; + gap: 1rem; +} + +.cameraColumn { + display: flex; + flex: 1; + flex-direction: column; + gap: 1rem; +} + +.cameraInput { + display: flex; + gap: 1rem; + align-items: center; +} \ No newline at end of file diff --git a/ethernet-view/src/pages/CamerasPage/CamerasPage.tsx b/ethernet-view/src/pages/CamerasPage/CamerasPage.tsx new file mode 100644 index 000000000..7edc867f3 --- /dev/null +++ b/ethernet-view/src/pages/CamerasPage/CamerasPage.tsx @@ -0,0 +1,45 @@ +import { Button, LiveStreamPlayer, TextInput } from 'common'; +import styles from './CamerasPage.module.scss'; +import { useState } from 'react'; + +export const CamerasPage = () => { + const [cameras, setCameras] = useState>(new Map()); + const [cameraLeftURL, setCameraLeftURL] = useState(''); + const [cameraRightURL, setCameraRightURL] = useState(''); + + const handleSetCamera = (cameraId: number, cameraURL: string) => () => { + setCameras((prev) => new Map(prev).set(cameraId, cameraURL)); + }; + + return ( +
+
+
+
+ setCameraLeftURL(e.target.value)} + /> +
+ +
+ +
+
+ setCameraRightURL(e.target.value)} + /> +
+ +
+
+
+ ); +}; diff --git a/ethernet-view/src/pages/LoggerPage/ChartsColumn/ChartMenu/ChartElement/ChartCanvas.tsx b/ethernet-view/src/pages/LoggerPage/ChartsColumn/ChartMenu/ChartElement/ChartCanvas.tsx new file mode 100644 index 000000000..fc6100748 --- /dev/null +++ b/ethernet-view/src/pages/LoggerPage/ChartsColumn/ChartMenu/ChartElement/ChartCanvas.tsx @@ -0,0 +1,127 @@ +import { + ColorType, + createChart, + IChartApi, + UTCTimestamp, +} from 'lightweight-charts'; +import { ChartPoint } from 'pages/LoggerPage/LogsColumn/LogLoader/LogsProcessor'; +import { useEffect, useRef } from 'react'; +import { MeasurementLogger } from './ChartElement'; + +const CHART_HEIGHT = 300; + +interface Props { + measurementsInChart: MeasurementLogger[]; + getDataFromLogSession: (measurement: string) => ChartPoint[]; +} + +export const ChartCanvas = ({ + measurementsInChart, + getDataFromLogSession, +}: Props) => { + const chart = useRef(null); + const chartContainerRef = useRef(null); + + // Helper function to get theme-aware chart options + const getThemeOptions = () => { + const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; + return { + layout: { + background: { + type: ColorType.Solid, + color: isDark ? 'black' : 'white' + }, + textColor: isDark ? 'white' : 'black', + }, + grid: { + vertLines: { + color: isDark ? '#1f1f1f' : '#f0f0f0', + }, + horzLines: { + color: isDark ? '#1f1f1f' : '#f0f0f0', + }, + }, + }; + }; + + useEffect(() => { + const handleResize = () => { + if (chartContainerRef.current) + if (chart) + chart.current?.applyOptions({ + width: chartContainerRef.current.clientWidth, + }); + }; + + const resizeObserver = new ResizeObserver(handleResize); + if (chartContainerRef.current) + resizeObserver.observe(chartContainerRef.current); + + let themeObserver: MutationObserver | null = null; + + if (chartContainerRef.current) { + if (chart) + chart.current = createChart(chartContainerRef.current, { + ...getThemeOptions(), + width: chartContainerRef.current.clientWidth, + height: CHART_HEIGHT, + timeScale: { + timeVisible: true, + fixLeftEdge: true, + fixRightEdge: true, + lockVisibleTimeRangeOnResize: true, + tickMarkFormatter: (time: UTCTimestamp) => { + const date = new Date(time * 1000); + return date.toLocaleTimeString() + '.' + date.getMilliseconds(); + }, + }, + localization: { + timeFormatter: (time: UTCTimestamp) => { + const date = new Date(time * 1000); + return date.toLocaleTimeString() + '.' + date.getMilliseconds(); + } + } + }); + + // Set up MutationObserver to watch for theme changes + themeObserver = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'attributes' && mutation.attributeName === 'data-theme') { + chart.current?.applyOptions(getThemeOptions()); + } + }); + }); + + themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme'] + }); + } + + for (const measurement of measurementsInChart) { + const data = getDataFromLogSession(measurement.id); + const series = chart.current?.addLineSeries({ + color: measurement.color, + priceFormat: { + type: 'price', + precision: 3, + minMove: 0.001, + }, + }); + for (const point of data) { + series?.update({ + time: (point.time / 1000) as UTCTimestamp, + value: point.value, + }); + } + } + + return () => { + resizeObserver.disconnect(); + themeObserver?.disconnect(); + chart.current?.remove(); + }; + }, [measurementsInChart]); + + return
; +}; diff --git a/ethernet-view/src/pages/LoggerPage/ChartsColumn/ChartMenu/ChartElement/ChartElement.tsx b/ethernet-view/src/pages/LoggerPage/ChartsColumn/ChartMenu/ChartElement/ChartElement.tsx new file mode 100644 index 000000000..dcf6fecca --- /dev/null +++ b/ethernet-view/src/pages/LoggerPage/ChartsColumn/ChartMenu/ChartElement/ChartElement.tsx @@ -0,0 +1,74 @@ +import { ChartId } from "components/ChartMenu/ChartMenu" +import { AiOutlineCloseCircle } from "react-icons/ai" +import styles from "components/ChartMenu/ChartElement/ChartElement.module.scss" +import { ChartCanvas } from "./ChartCanvas"; +import { ChartPoint } from "pages/LoggerPage/LogsColumn/LogLoader/LogsProcessor"; +import { useState } from "react"; +import { MeasurementId, } from "common"; +import { ChartLegend } from "./ChartLegend"; + +interface Props { + chartId: ChartId; + initialMeasurementId: MeasurementId + removeChart: (chartId: ChartId) => void; + getDataFromLogSession: (measurement: ChartId) => ChartPoint[]; +} + +export interface MeasurementLogger { + id: string; + color: string; +} + +export const ChartElement = ({ chartId, initialMeasurementId, removeChart, getDataFromLogSession }: Props) => { + + const [measurementsInChart, setMeasurementsInChart] = useState([{id: initialMeasurementId, color: 'red'}]); + + const addMeasurementToChart = (measurement: MeasurementLogger) => { + if(!measurementsInChart.some(measurementInChart => measurementInChart.id === measurement.id)) { + setMeasurementsInChart([...measurementsInChart, measurement]); + } + } + + const handleDrop = (ev: React.DragEvent) => { + ev.stopPropagation(); + const id = ev.dataTransfer.getData("id"); + addMeasurementToChart({id, color: getRandomColor()}); + }; + + return ( +
ev.preventDefault()} + onDragOver={(ev) => ev.preventDefault()} + > +
+ removeChart(chartId)} + /> + + setMeasurementsInChart(measurementsInChart.filter(measurement => measurement.id !== measurementId))} + /> +
+
+ ) +} + +function getRandomColor() { + var r = Math.floor(Math.random() * 256); + var g = Math.floor(Math.random() * 256); + var b = Math.floor(Math.random() * 256); + var hexR = r.toString(16).padStart(2, '0'); + var hexG = g.toString(16).padStart(2, '0'); + var hexB = b.toString(16).padStart(2, '0'); + return '#' + hexR + hexG + hexB; + } \ No newline at end of file diff --git a/ethernet-view/src/pages/LoggerPage/ChartsColumn/ChartMenu/ChartElement/ChartLegend.tsx b/ethernet-view/src/pages/LoggerPage/ChartsColumn/ChartMenu/ChartElement/ChartLegend.tsx new file mode 100644 index 000000000..efea169f9 --- /dev/null +++ b/ethernet-view/src/pages/LoggerPage/ChartsColumn/ChartMenu/ChartElement/ChartLegend.tsx @@ -0,0 +1,54 @@ +import { MeasurementId } from "common"; +import { ChartId } from "components/ChartMenu/ChartMenu"; +import { useEffect, useRef } from "react"; +import styles from "components/ChartMenu/ChartElement/ChartLegend/ChartLegend.module.scss" +import { MeasurementLogger } from "./ChartElement"; + +interface Props { + chartId: ChartId; + measurementsInChart: MeasurementLogger[]; + removeMeasurementFromChart: (measurementId: MeasurementId) => void; + removeChart: (chartId: ChartId) => void; +} + +export const ChartLegend = ({ chartId, measurementsInChart, removeMeasurementFromChart, removeChart }: Props) => { + + const legendRef = useRef(null); + + const onRemoveMeasurement = (measurementId: MeasurementId) => { + removeMeasurementFromChart(measurementId); + }; + + useEffect(() => { + if(measurementsInChart.length == 0) removeChart(chartId); + }, [measurementsInChart.length]) + + useEffect(() => { + if (legendRef.current) { + while (legendRef.current.firstChild) { + legendRef.current.removeChild(legendRef.current.firstChild); + } + measurementsInChart.forEach((measurement) => { + const newChartLegendItem = createChartLegendItem(measurement); + newChartLegendItem.onclick = (_) => onRemoveMeasurement(measurement.id); + legendRef.current?.appendChild(newChartLegendItem); + }); + } + }); + + return
; +}; + +function createChartLegendItem(measurement: MeasurementLogger) { + const legendItem = document.createElement("div"); + legendItem.setAttribute("data-id", measurement.id); + legendItem.className = styles.chartLegendItem; + const seriesColor = document.createElement("div"); + seriesColor.className = styles.chartLegendItemColor; + seriesColor.style.backgroundColor = measurement.color; + const seriesName = document.createElement("p"); + seriesName.innerText = measurement.id; + legendItem.appendChild(seriesColor); + legendItem.appendChild(seriesName); + return legendItem; +} diff --git a/ethernet-view/src/pages/LoggerPage/ChartsColumn/ChartMenu/ChartMenu.tsx b/ethernet-view/src/pages/LoggerPage/ChartsColumn/ChartMenu/ChartMenu.tsx new file mode 100644 index 000000000..6df6cd5ab --- /dev/null +++ b/ethernet-view/src/pages/LoggerPage/ChartsColumn/ChartMenu/ChartMenu.tsx @@ -0,0 +1,62 @@ +import { ChartId, ChartInfo } from "components/ChartMenu/ChartMenu" +import styles from "components/ChartMenu/ChartMenu.module.scss" +import { Item } from "components/ChartMenu/Sidebar/Section/Subsection/Subsection/Items/Item/ItemView" +import Sidebar from "components/ChartMenu/Sidebar/Sidebar" +import { useLogStore } from "pages/LoggerPage/useLogStore" +import { DragEvent, useCallback, useState } from "react" +import { ChartElement } from "./ChartElement/ChartElement" +import { nanoid } from "nanoid" +import { MeasurementId } from "common" + +export const ChartMenu = () => { + const openLogSession = useLogStore((state) => state.openLogSession) + const logSessions = useLogStore((state) => state.logSessions) + const logSession = logSessions.find((logSession) => logSession.name === openLogSession) + const data = logSession ? Array.from(logSession.measurementLogs.keys()).sort() : [] + const sidebarItems: Item[] = data.map(measurement => ({ + id: measurement, + name: measurement, + })) + + const getDataFromLogSession = (measurement: string) => { + return logSession?.measurementLogs.get(measurement) || []; + } + + const [charts, setCharts] = useState([]); + + const addChart = ((chartId: ChartId, initialMeasurementId: MeasurementId) => { + setCharts([...charts, { chartId, initialMeasurementId }]); + }); + + const removeChart = useCallback((chartId: ChartId) => { + setCharts(prevCharts => prevCharts.filter(c => chartId !== c.chartId)); + }, []); + + const handleDrop = (ev: DragEvent) => { + ev.preventDefault(); + const id = ev.dataTransfer.getData("id"); + addChart(nanoid(), id); + }; + + return ( +
+ +
ev.preventDefault()} + onDragOver={(ev) => ev.preventDefault()} + > + {charts.map((chart) => ( + removeChart(chartId)} + getDataFromLogSession={getDataFromLogSession} + /> + ))} +
+
+ ) +} diff --git a/ethernet-view/src/pages/LoggerPage/ChartsColumn/ChartsColumn.tsx b/ethernet-view/src/pages/LoggerPage/ChartsColumn/ChartsColumn.tsx new file mode 100644 index 000000000..2d9b00222 --- /dev/null +++ b/ethernet-view/src/pages/LoggerPage/ChartsColumn/ChartsColumn.tsx @@ -0,0 +1,16 @@ +import { TabLayout } from "layouts/TabLayout/TabLayout"; +import { ChartMenu } from "./ChartMenu/ChartMenu"; +import { ReactComponent as Chart } from "assets/svg/chart.svg"; + +export const ChartsColumn = () => { + const chartsColumnTabItems = [ + { + id: "charts", + name: "Charts", + icon: , + component: , + }, + ] + + return ; +}; \ No newline at end of file diff --git a/ethernet-view/src/pages/LoggerPage/LoggerPage.module.scss b/ethernet-view/src/pages/LoggerPage/LoggerPage.module.scss new file mode 100644 index 000000000..7841138b2 --- /dev/null +++ b/ethernet-view/src/pages/LoggerPage/LoggerPage.module.scss @@ -0,0 +1,13 @@ +.LoggerPage { + display: flex; + width: 100%; + gap: 1rem; +} + +.LogsColumn { + flex: 1; +} + +.ChartsColumn { + flex: 3; +} \ No newline at end of file diff --git a/ethernet-view/src/pages/LoggerPage/LoggerPage.tsx b/ethernet-view/src/pages/LoggerPage/LoggerPage.tsx new file mode 100644 index 000000000..da3b0b974 --- /dev/null +++ b/ethernet-view/src/pages/LoggerPage/LoggerPage.tsx @@ -0,0 +1,23 @@ +import { ChartsColumn } from "pages/LoggerPage/ChartsColumn/ChartsColumn"; +import { LogsColumn } from "./LogsColumn/LogsColumn"; +import { SplitLayout } from "layouts/SplitLayout/SplitLayout"; +import logs from "assets/svg/logs.svg"; +import chart from "assets/svg/chart.svg"; + +export const LoggerPage = () => { + return ( + , + collapsedIcon: logs + }, + { + component: , + collapsedIcon: chart + } + ]} + initialLengths={[0.3, 0.7]} + /> + ); +}; diff --git a/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/Dropzone/Controls/Controls.module.scss b/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/Dropzone/Controls/Controls.module.scss new file mode 100644 index 000000000..a3eb366d5 --- /dev/null +++ b/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/Dropzone/Controls/Controls.module.scss @@ -0,0 +1,13 @@ +.controlsWrapper { + width: 100%; + display: flex; + padding: 1rem; + gap: 2rem; +} + +.button { + flex: 1; + padding: 0.2rem; + font-size: large; + border-style: none; +} \ No newline at end of file diff --git a/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/Dropzone/Controls/Controls.tsx b/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/Dropzone/Controls/Controls.tsx new file mode 100644 index 000000000..f1e550d65 --- /dev/null +++ b/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/Dropzone/Controls/Controls.tsx @@ -0,0 +1,30 @@ +import { Button } from "components/FormComponents/Button/Button" +import styles from "./Controls.module.scss" +import { UploadInformation, UploadState } from "../../LogLoader"; + +type Props = { + uploadInformation: UploadInformation; + onLoad: () => void; + onRemove: () => void; +} + +export const Controls = ({uploadInformation, onLoad, onRemove}: Props) => { + return ( +
+ +
+ ) +} diff --git a/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/Dropzone/Dropzone.module.scss b/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/Dropzone/Dropzone.module.scss new file mode 100644 index 000000000..dad168642 --- /dev/null +++ b/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/Dropzone/Dropzone.module.scss @@ -0,0 +1,43 @@ +@use "src/styles/styles.scss"; +@use "src/styles/colors" as colors; + +.dropZone { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + padding: 3rem 2rem; + border: dashed colors.getThemeColor("border"); + border-radius: styles.$large-border-radius; + + &Text { + font-weight: styles.$normal-font-weight; + font-size: styles.$normal-font-size; + color: colors.getThemeColor("text-secondary"); + font-family: styles.$code-font; + } + + &.Active { + background-color: colors.getThemeColor("primary-surface"); + } + + &.Uploading { + background-color: colors.getThemeColor("primary-surface"); + } + + &.Success { + background-color: colors.getThemeColor("success"); + opacity: 0.15; + } + + &.Error { + background-color: colors.getThemeColor("error"); + opacity: 0.15; + } +} + +.selectLabel { + cursor: pointer; + font-weight: bold; + color: colors.getThemeColor("primary"); +} \ No newline at end of file diff --git a/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/Dropzone/Dropzone.tsx b/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/Dropzone/Dropzone.tsx new file mode 100644 index 000000000..4f0d117eb --- /dev/null +++ b/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/Dropzone/Dropzone.tsx @@ -0,0 +1,126 @@ +import { useState } from "react"; +import { UploadInformation, UploadState } from "../LogLoader"; +import styles from "./Dropzone.module.scss" +import { extractLoggerSession } from "../LogsProcessor"; +import { Controls } from "./Controls/Controls"; +import { useDropzone } from "./useDropzone"; +import { LogSession, useLogStore } from "pages/LoggerPage/useLogStore"; + +export const Dropzone = () => { + + const [uploadInformation, setUploadInformation] = useState({state: UploadState.IDLE}); + const [isDraggingOver, setIsDraggingOver] = useState(false); + const {dropZoneText, draftSession, setDraftSession} = useDropzone({uploadInformation}); + const addLogSession = useLogStore(state => state.addLogSession); + + const uploadSession = async (directory: FileSystemDirectoryEntry) => { + setUploadInformation({state: UploadState.UPLOADING}); + try { + const loggerSession = await extractLoggerSession(directory); + setDraftSession({name: directory.name, measurementLogs: loggerSession} as LogSession); + setUploadInformation({state: UploadState.SUCCESS}); + } catch(err) { + if(err instanceof Error) { + setUploadInformation({state: UploadState.ERROR, errorMessage: err.message}); + } else { + setUploadInformation({state: UploadState.ERROR, errorMessage: "An unexpected error occurred"}); + } + } + }; + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault(); + try { + validateEntry(e.dataTransfer.items); + const directory = e.dataTransfer.items[0].webkitGetAsEntry(); + console.log(directory) + await uploadSession(directory as FileSystemDirectoryEntry); + } catch(err) { + if (err instanceof Error){ + setUploadInformation({state: UploadState.ERROR, errorMessage: err.cause as string}); + } else { + setUploadInformation({state: UploadState.ERROR, errorMessage: "An unexpected error occurred"}); + } + } + }; + + return ( +
+
{ + e.preventDefault(); + setIsDraggingOver(true); + }} + onDragLeave={e => { + e.preventDefault(); + if (e.currentTarget.contains(e.relatedTarget as Node)) return; + setIsDraggingOver(false); + }} + onDragOver={e => e.preventDefault()} + onDrop={handleDrop} + > +
+

+ {dropZoneText} + {uploadInformation.state === UploadState.IDLE && +

+ + { + if(ev.target.files && ev.target.files.length === 1) { + + } + }} + > + +
+ } + {uploadInformation.errorMessage} +

+
+
+ + + { + if(draftSession) { + addLogSession(draftSession); + setUploadInformation({state: UploadState.IDLE}); + setDraftSession(undefined); + } + }} + onRemove={() => { + setUploadInformation({state: UploadState.IDLE}); + setDraftSession(undefined); + }} + /> + +
+ ) +} + +/** + * The function `validateEntry` checks if only one directory is selected from a list of files. + * @param {DataTransferItemList} files - The `files` parameter in the `validateEntry` function is of + * type `DataTransferItemList`, which represents a list of items. + */ +function validateEntry(files: DataTransferItemList) { + if(files.length != 1) throw new Error("Only one file or directory is allowed"); + let file = files[0].webkitGetAsEntry(); + if(!file) throw new Error("Invalid file"); + if(!file.isDirectory) throw new Error("Only directories are allowed"); +} \ No newline at end of file diff --git a/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/Dropzone/useDropzone.ts b/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/Dropzone/useDropzone.ts new file mode 100644 index 000000000..a0ba6822d --- /dev/null +++ b/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/Dropzone/useDropzone.ts @@ -0,0 +1,29 @@ +import { useEffect, useState } from "react"; +import { UploadInformation, UploadState } from "../LogLoader"; +import { LogSession } from "pages/LoggerPage/useLogStore"; + +interface Props { + uploadInformation: UploadInformation; +} + +export const useDropzone = ({uploadInformation}: Props) => { + + const [dropZoneText, setDropZoneText] = useState(); + const [draftSession, setDraftSession] = useState(); + + useEffect(() => { + setDropZoneText(`${ + uploadInformation.state === UploadState.UPLOADING ? "Uploading..." : + uploadInformation.state === UploadState.SUCCESS ? "Upload successful" : + uploadInformation.state === UploadState.ERROR ? "Upload failed" : + "Drop a logger session directory here" + }`); + }, [uploadInformation]); + + return { + dropZoneText, + setDraftSession, + draftSession + } + +} diff --git a/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/LogList/LogItem/LogItem.module.scss b/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/LogList/LogItem/LogItem.module.scss new file mode 100644 index 000000000..c350adbec --- /dev/null +++ b/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/LogList/LogItem/LogItem.module.scss @@ -0,0 +1,34 @@ +@use "src/styles/styles.scss"; +@use "src/styles/colors" as colors; + +.logItemWrapper { + display: flex; + align-items: center; + justify-content: center; + gap: .8rem; + width: 100%; + + &:hover { + cursor: pointer; + } +} + +.icon { + img { + width: 1.6rem; + } +} + +.title { + color: styles.$title-color; + font-size: 1.6rem; + font-family: styles.$code-font; + font-style: italic; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +.active { + color: colors.getThemeColor("secondary"); +} \ No newline at end of file diff --git a/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/LogList/LogItem/LogItem.tsx b/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/LogList/LogItem/LogItem.tsx new file mode 100644 index 000000000..05c45db70 --- /dev/null +++ b/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/LogList/LogItem/LogItem.tsx @@ -0,0 +1,35 @@ +import { useLogStore } from "pages/LoggerPage/useLogStore"; +import styles from "./LogItem.module.scss" +import folderClosed from "assets/svg/folder-closed.svg" +import folderOpen from "assets/svg/folder-open.svg" +import cross from "assets/svg/cross.svg" + +interface Props { + logName: string; +} + +export const LogItem = ({logName}: Props) => { + + const openLogSession = useLogStore(state => state.openLogSession); + const setOpenLogSession = useLogStore(state => state.setOpenLogSession); + const removeLogSession = useLogStore(state => state.removeLogSession); + const isOpen = openLogSession === logName; + + const handleRemoveLog = () => { + removeLogSession(logName); + } + + return ( +
setOpenLogSession(logName)}> +
+ log-icon +
+
+ {logName} +
+
+ Remove log +
+
+ ) +} diff --git a/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/LogList/LogList.module.scss b/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/LogList/LogList.module.scss new file mode 100644 index 000000000..e7855b329 --- /dev/null +++ b/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/LogList/LogList.module.scss @@ -0,0 +1,5 @@ +.logListWrapper { + display: flex; + flex-direction: column; + align-items: center; +} \ No newline at end of file diff --git a/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/LogList/LogList.tsx b/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/LogList/LogList.tsx new file mode 100644 index 000000000..eb6c450f7 --- /dev/null +++ b/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/LogList/LogList.tsx @@ -0,0 +1,16 @@ +import { LogItem } from "./LogItem/LogItem"; +import styles from "./LogList.module.scss" + +interface Props { + logNames: string[]; +} + +export const LogList = ({logNames}: Props) => { + return ( +
+ {logNames.map((logName) => ( + + ))} +
+ ) +} diff --git a/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/LogLoader.module.scss b/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/LogLoader.module.scss new file mode 100644 index 000000000..09d378956 --- /dev/null +++ b/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/LogLoader.module.scss @@ -0,0 +1,6 @@ +.logLoaderWrapper { + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; +} \ No newline at end of file diff --git a/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/LogLoader.tsx b/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/LogLoader.tsx new file mode 100644 index 000000000..a0e70d54b --- /dev/null +++ b/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/LogLoader.tsx @@ -0,0 +1,31 @@ +import { Dropzone } from "./Dropzone/Dropzone" +import styles from "./LogLoader.module.scss" +import { LogList } from "./LogList/LogList"; +import { useLogStore } from "pages/LoggerPage/useLogStore"; + +export enum UploadState { + IDLE = "idle", + UPLOADING = "uploading", + SUCCESS = "success", + ERROR = "error", +} + +export interface UploadInformation { + state: UploadState; + errorMessage?: string; +} + +export const LogLoader = () => { + + const logSessions = useLogStore(state => state.logSessions); + + return ( +
+ + logSession.name)} /> + + + +
+ ) +} diff --git a/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/LogsProcessor.ts b/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/LogsProcessor.ts new file mode 100644 index 000000000..6e722fc8d --- /dev/null +++ b/ethernet-view/src/pages/LoggerPage/LogsColumn/LogLoader/LogsProcessor.ts @@ -0,0 +1,108 @@ +import Papa from 'papaparse'; + +/** + * The function `processLoggerSession` reads CSV files from a directory, parses the data, filters out + * invalid logs, and returns a map of file names to arrays of valid log entries with timestamps and + * values. + * @param {FileSystemDirectoryEntry} directory - The `directory` parameter in the + * `processLoggerSession` function is expected to be a FileSystemDirectoryEntry object representing a + * directory in the file system. This directory is used to retrieve files for processing in the + * function. + * @returns The `processLoggerSession` function returns a Promise that resolves to a Map object. This map contains entries where the key is the name of a CSV file representing + * a Measurement, and the value is an array of objects with a `time` property of type Date and + * a `value` property of type number, representing an array of points with all the data + * retrieved from the CSV file. + */ + +export type ChartPoint = {time: number, value: number}; +export type Session = Map; + +export async function extractLoggerSession (directory: FileSystemDirectoryEntry): Promise { + const session: Session = new Map(); + const files = await getFilesFromDirectory(directory); + if(files.length === 0) throw new Error("No files found in the directory."); + + for(const file of files) { + try { + if(!file.isFile) throw new Error(`Invalid entry ${file.name}. Expected a file.`); + if(!file.name.endsWith(".csv")) throw new Error(`Invalid file ${file.name}. Expected a CSV file.`); + (file as FileSystemFileEntry).file((file) => { + Papa.parse(file, { + complete: (result) => { + const measurementPoints = [] as ChartPoint[]; + let lastTimestamp = 0; + for(const row of result.data) { + if(isValidLog(row)) { + const [timestamp, , , value] = row as [string, string, string, string]; + if(parseInt(timestamp) <= lastTimestamp) continue; + measurementPoints.push({ + time: parseFloat(timestamp), + value: parseFloat(value) + }); + lastTimestamp = parseInt(timestamp); + } + } + session.set(file.name.replace(".csv", ""), measurementPoints); + }, + error: (err) => { + throw new Error(`Error parsing file ${file.name}. ${err}`); + }, + header: false + }); + }); + } catch(err) { + if(err instanceof Error) { + console.error(err.message); + } else { + console.error("An unexpected error occurred"); + } + } + }; + + return session; +}; + +/** + * The function `getFilesFromDirectory` asynchronously retrieves the entries (files and directories) + * from a given directory in a file system. + * @param {FileSystemDirectoryEntry} folder - FileSystemDirectoryEntry - Represents a directory in the + * file system. + * @returns The function `getFilesFromDirectory` returns a Promise that resolves to an array of + * `FileSystemEntry` objects representing the files in the specified directory. + */ + +export async function getFilesFromDirectory(folder: FileSystemDirectoryEntry): Promise { + const entries: FileSystemEntry[] = []; + + async function readEntries(reader: FileSystemDirectoryReader) { + const result = await new Promise((resolve, reject) => { + reader.readEntries((results) => { + resolve(results); + }, (err) => { + reject(err); + }); + }); + + if (result.length > 0) { + entries.push(...result); + await readEntries(reader); + } + } + + await readEntries(folder.createReader()); + + return entries; +} + + +function isValidLog(row: unknown) { + return ( + Array.isArray(row) && + row.length === 4 && + // new Date(row[0]).to() !== "Invalid Date" && + typeof row[1] === "string" && + typeof row[2] === "string" && + !isNaN(parseFloat(row[3])) + ); +} \ No newline at end of file diff --git a/ethernet-view/src/pages/LoggerPage/LogsColumn/LogsColumn.tsx b/ethernet-view/src/pages/LoggerPage/LogsColumn/LogsColumn.tsx new file mode 100644 index 000000000..e66c5c414 --- /dev/null +++ b/ethernet-view/src/pages/LoggerPage/LogsColumn/LogsColumn.tsx @@ -0,0 +1,17 @@ +import { TabItem } from "layouts/TabLayout/TabItem"; +import { TabLayout } from "layouts/TabLayout/TabLayout" +import { LogLoader } from "./LogLoader/LogLoader"; + +export const LogsColumn = () => { + + const logsColumnTabItems : TabItem[] = [ + { + id: "logs", + name: "Logs", + icon: null, + component: + } + ]; + + return +} diff --git a/ethernet-view/src/pages/LoggerPage/useLogStore.ts b/ethernet-view/src/pages/LoggerPage/useLogStore.ts new file mode 100644 index 000000000..48e0c9303 --- /dev/null +++ b/ethernet-view/src/pages/LoggerPage/useLogStore.ts @@ -0,0 +1,38 @@ +import { create } from "zustand"; +import { ChartPoint } from "./LogsColumn/LogLoader/LogsProcessor"; + +export interface LogSession { + name: string; + measurementLogs: Map; +} +export type LogSessionCollection = LogSession[]; + +type LogStore = { + logSessions: LogSessionCollection; + openLogSession: string; + setOpenLogSession: (logSession: string) => void; + addLogSession: (log: LogSession) => void; + clearLogSessions: () => void; + removeLogSession: (logName: string) => void; +}; + + +/* + Zustand store for managing all the logger sessions uploaded. + It is the nexo between the LogLoader and the LogsColumn components. +*/ +export const useLogStore = create((set, get) => ({ + logSessions: [], + openLogSession: "", + setOpenLogSession: (logSession: string) => { + set((state) => ({ ...state, openLogSession: logSession })); + }, + addLogSession: (log: LogSession) => { + if (get().logSessions.find((logSession) => logSession.name === log.name)) return; + set((state) => ({ logSessions: [...state.logSessions, log] })); + }, + clearLogSessions: () => set({ logSessions: [] }), + removeLogSession: (logName: string) => { + set((state) => ({ logSessions: state.logSessions.filter((logSession) => logSession.name !== logName) })); + } +})); diff --git a/ethernet-view/src/pages/TestingPage/ChartsColumn/ChartsColumn.tsx b/ethernet-view/src/pages/TestingPage/ChartsColumn/ChartsColumn.tsx new file mode 100644 index 000000000..b7b3db212 --- /dev/null +++ b/ethernet-view/src/pages/TestingPage/ChartsColumn/ChartsColumn.tsx @@ -0,0 +1,32 @@ +import { TabLayout } from "layouts/TabLayout/TabLayout"; +import { ChartMenu } from "components/ChartMenu/ChartMenu"; +import { ReactComponent as Chart } from "assets/svg/chart.svg"; +import { useMemo } from "react"; +import { useMeasurementsStore, usePodDataStore, useSubscribe } from "common"; +import { createSidebarSections } from "components/ChartMenu/sidebar"; + +export const ChartsColumn = () => { + const podData = usePodDataStore(state => state.podData); + const updatePodData = usePodDataStore(state => state.updatePodData) + const updateMeasurements = useMeasurementsStore(state => state.updateMeasurements) + + useSubscribe("podData/update", (update) => { + updatePodData(update); + updateMeasurements(update); + }); + + const sections = useMemo(() => { + return createSidebarSections(podData); + }, []); + + const chartsColumnTabItems = [ + { + id: "charts", + name: "Charts", + icon: , + component: , + }, + ]; + + return ; +}; \ No newline at end of file diff --git a/ethernet-view/src/pages/TestingPage/MessagesColumn/MessagesColumn.module.scss b/ethernet-view/src/pages/TestingPage/MessagesColumn/MessagesColumn.module.scss new file mode 100644 index 000000000..afd765e76 --- /dev/null +++ b/ethernet-view/src/pages/TestingPage/MessagesColumn/MessagesColumn.module.scss @@ -0,0 +1,6 @@ +.messageColumnWrapper { + height: 100%; + display: flex; + flex-direction: column; + gap: 1rem; +} diff --git a/ethernet-view/src/pages/TestingPage/MessagesColumn/MessagesColumn.tsx b/ethernet-view/src/pages/TestingPage/MessagesColumn/MessagesColumn.tsx new file mode 100644 index 000000000..88aec79d0 --- /dev/null +++ b/ethernet-view/src/pages/TestingPage/MessagesColumn/MessagesColumn.tsx @@ -0,0 +1,55 @@ +import styles from "pages/TestingPage/MessagesColumn/MessagesColumn.module.scss"; +import { SplitLayout } from "layouts/SplitLayout/SplitLayout"; +import { Orientation } from "hooks/useSplit/Orientation"; +import { TabLayout } from "layouts/TabLayout/TabLayout"; +import { BiLineChart } from "react-icons/bi"; +import { nanoid } from "nanoid"; +import { MessagesContainer } from "components/MessagesContainer/MessagesContainer"; +import { Logger } from "components/Logger/Logger"; +import { useRef } from "react"; +import { Connections } from "common"; +import { BootloaderContainer } from "components/BootloaderContainer/BootloaderContainer"; +import letter from "assets/svg/letter.svg" +import connection from "assets/svg/connection.svg" + +export const MessagesColumn = () => { + const messagesTabItems = useRef([ + { + id: nanoid(), + name: "Messages", + icon: , + + component: , + }, + ]); + + const connectionsTabItems = useRef([ + { + id: nanoid(), + name: "Connections", + icon: , + + component: , + } + ]) + + return ( +
+ , + collapsedIcon: letter, + }, + { + component: , + collapsedIcon: connection, + }, + ]} + orientation={Orientation.VERTICAL} + > + + +
+ ); +}; diff --git a/ethernet-view/src/pages/TestingPage/OrderColumn/OrderColumn.tsx b/ethernet-view/src/pages/TestingPage/OrderColumn/OrderColumn.tsx new file mode 100644 index 000000000..145c64c58 --- /dev/null +++ b/ethernet-view/src/pages/TestingPage/OrderColumn/OrderColumn.tsx @@ -0,0 +1,18 @@ +import { TabLayout } from "layouts/TabLayout/TabLayout"; +import { nanoid } from "nanoid"; +import { OrdersContainer } from "components/OrdersContainer/OrdersContainer"; +import { useRef } from "react"; +import { ReactComponent as OutgoingMessage } from "assets/svg/outgoing-message.svg"; + +export const OrderColumn = () => { + const orderTabItems = useRef([ + { + id: nanoid(), + name: "Orders", + icon: , + component: , + }, + ]); + + return ; +}; diff --git a/ethernet-view/src/pages/TestingPage/ReceiveColumn/ReceiveColumn.module.scss b/ethernet-view/src/pages/TestingPage/ReceiveColumn/ReceiveColumn.module.scss new file mode 100644 index 000000000..ff1a5f0df --- /dev/null +++ b/ethernet-view/src/pages/TestingPage/ReceiveColumn/ReceiveColumn.module.scss @@ -0,0 +1,8 @@ +.loadingMessages { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + font-size: 2rem; +} diff --git a/ethernet-view/src/pages/TestingPage/ReceiveColumn/ReceiveColumn.tsx b/ethernet-view/src/pages/TestingPage/ReceiveColumn/ReceiveColumn.tsx new file mode 100644 index 000000000..fe40bbb8b --- /dev/null +++ b/ethernet-view/src/pages/TestingPage/ReceiveColumn/ReceiveColumn.tsx @@ -0,0 +1,21 @@ +import { TabLayout } from "layouts/TabLayout/TabLayout"; +import { ReactComponent as IncomingMessage } from "assets/svg/incoming-message.svg"; +import { ReceiveTable } from "components/ReceiveTable/ReceiveTable"; +import { usePodDataStore } from "common"; + +export const ReceiveColumn = () => { + const podData = usePodDataStore(state => state.podData) + + const receiveColumnTabItems = [ + { + id: "receiveTable", + name: "Packets", + icon: , + component: ( + + ), + }, + ] + + return ; +}; \ No newline at end of file diff --git a/ethernet-view/src/pages/TestingPage/TestingPage.module.scss b/ethernet-view/src/pages/TestingPage/TestingPage.module.scss new file mode 100644 index 000000000..e91380518 --- /dev/null +++ b/ethernet-view/src/pages/TestingPage/TestingPage.module.scss @@ -0,0 +1,19 @@ +@use "src/styles/styles"; + +#wrapper { + width: 100%; + height: 100%; + display: grid; + grid-template: ". body" / auto 1fr; +} + +#wrapper > * { + min-height: 0; + min-width: 0; +} + +#body { + grid-area: body; + width: 100%; + height: 100%; +} diff --git a/ethernet-view/src/pages/TestingPage/TestingPage.tsx b/ethernet-view/src/pages/TestingPage/TestingPage.tsx new file mode 100644 index 000000000..e16f6eee3 --- /dev/null +++ b/ethernet-view/src/pages/TestingPage/TestingPage.tsx @@ -0,0 +1,43 @@ +import { SplitLayout } from "layouts/SplitLayout/SplitLayout"; +import { Orientation } from "hooks/useSplit/Orientation"; +import { ReceiveColumn } from "pages/TestingPage/ReceiveColumn/ReceiveColumn"; +import { OrderColumn } from "pages/TestingPage/OrderColumn/OrderColumn"; +import { MessagesColumn } from "pages/TestingPage/MessagesColumn/MessagesColumn"; +import { ChartsColumn } from "./ChartsColumn/ChartsColumn"; +import styles from "pages/TestingPage/TestingPage.module.scss"; +import incomingMessage from "assets/svg/incoming-message.svg"; +import paperAirplane from "assets/svg/paper-airplane.svg"; +import outgoingMessage from "assets/svg/outgoing-message.svg"; +import chart from "assets/svg/chart.svg"; + +export const TestingPage = () => { + const components = [ + { + component: , + collapsedIcon: chart, + }, + { + component: , + collapsedIcon: incomingMessage, + }, + { + component: , + collapsedIcon: paperAirplane, + }, + { + component: , + collapsedIcon: outgoingMessage, + }, + ]; + + return ( +
+
+ +
+
+ ); +}; diff --git a/ethernet-view/src/services/public/images/logo-white.png b/ethernet-view/src/services/public/images/logo-white.png new file mode 100644 index 000000000..d11903fc9 Binary files /dev/null and b/ethernet-view/src/services/public/images/logo-white.png differ diff --git a/ethernet-view/src/services/public/svg/tab-edge.svg b/ethernet-view/src/services/public/svg/tab-edge.svg new file mode 100644 index 000000000..c3380d31d --- /dev/null +++ b/ethernet-view/src/services/public/svg/tab-edge.svg @@ -0,0 +1,37 @@ + + + + + + diff --git a/ethernet-view/src/services/useBootloader.ts b/ethernet-view/src/services/useBootloader.ts new file mode 100644 index 000000000..ef317b445 --- /dev/null +++ b/ethernet-view/src/services/useBootloader.ts @@ -0,0 +1,49 @@ +import { useWsHandler } from "common"; +import { nanoid } from "nanoid"; + +export type BootloaderUpload = { board: string; file: File }; + +export function useBootloader( + onDownloadSuccess: (file: File) => void, + onDownloadFailure: () => void, + onSendSuccess: () => void, + onSendFailure: () => void, + onProgress: (progress: number) => void // progress between 0 and 100 +) { + const handler = useWsHandler(); + + //TODO: timeout if it takes to long + const uploader = (board: string, file: string) => { + const id = nanoid(); + + handler.exchange("blcu/upload", { board, file }, id, (res, _, end) => { + if (res.percentage == 100) { + onSendSuccess(); + end(); + } else if (res.failure) { + onSendFailure(); + end(); + } else { + onProgress(res.percentage); + } + }); + }; + + //TODO: timeout if it takes to long + const downloader = (board: string) => { + const id = nanoid(); + handler.exchange("blcu/download", { board }, id, (res, _, end) => { + if (res.percentage == 100) { + onDownloadSuccess(new File([res.file], "program")); + end(); + } else if (res.failure) { + onDownloadFailure(); + end(); + } else { + onProgress(res.percentage); + } + }); + }; + + return { uploader, downloader } as const; +} diff --git a/ethernet-view/src/store/columnsStore.ts b/ethernet-view/src/store/columnsStore.ts new file mode 100644 index 000000000..0aa1fc3ed --- /dev/null +++ b/ethernet-view/src/store/columnsStore.ts @@ -0,0 +1,13 @@ +import { create } from "zustand"; + +export interface ColumnsStore { + columnSizes: string[]; + setColumnSizes: (setColumnSizes: string[]) => void; +}; + +// Zustand store for keeping track of column sizes. +// It is useful to the layout of the application. +export const useColumnsStore = create((set) => ({ + columnSizes: ["30%", "10%", "20%", "20%", "20%"] as string[], + setColumnSizes: (columnSizes: string[]) => set({ columnSizes }), +})); \ No newline at end of file diff --git a/ethernet-view/src/styles/colors.scss b/ethernet-view/src/styles/colors.scss new file mode 100644 index 000000000..427958611 --- /dev/null +++ b/ethernet-view/src/styles/colors.scss @@ -0,0 +1,149 @@ +@use "sass:color"; +@use "sass:map"; + +// Base colors +$colors: ( + "primary": hsl(212, 65%, 50%), + "secondary": hsl(25, 86%, 53%), + "surface": hsl(212, 22%, 88%), + "neutral": hsl(212, 15%, 50%), + "success": hsl(120, 60%, 50%), + "warning": hsl(45, 90%, 50%), + "error": hsl(0, 70%, 50%), + "info": hsl(200, 70%, 50%), +); + +// Generate color variations +@each $name, $color in $colors { + @for $i from 0 through 100 { + :root { + --color-#{$name}-#{$i}: #{color.adjust( + $color, + $lightness: $i * 1% - 50% + )}; + } + } +} + +// Light theme +:root, +:root[data-theme="light"] { + // Backgrounds + --theme-background: #dce3eb; + --theme-surface: #ffffff; + --theme-surface-variant: #f5f5f5; + --theme-surface-hover: #f0f0f0; + + // Text + --theme-text-primary: var(--color-neutral-15); + --theme-text-secondary: var(--color-neutral-30); + --theme-text-tertiary: var(--color-neutral-45); + --theme-text-disabled: var(--color-neutral-60); + + // Borders + --theme-border: var(--color-neutral-85); + --theme-border-variant: var(--color-neutral-90); + --theme-border-hover: var(--color-neutral-75); + + // Primary colors + --theme-primary: var(--color-primary-45); + --theme-primary-hover: var(--color-primary-40); + --theme-primary-surface: var(--color-primary-95); + --theme-primary-text: var(--color-primary-99); + + // Secondary colors + --theme-secondary: var(--color-secondary-50); + --theme-secondary-hover: var(--color-secondary-45); + --theme-secondary-surface: var(--color-secondary-90); + + // Status colors + --theme-success: var(--color-success-40); + --theme-warning: var(--color-warning-45); + --theme-error: var(--color-error-45); + --theme-info: var(--color-info-45); + + // Shadows + --theme-shadow-color: rgba(0, 0, 0, 0.1); + --theme-shadow-sm: 0 1px 2px var(--theme-shadow-color); + --theme-shadow-md: 0 4px 6px var(--theme-shadow-color); + --theme-shadow-lg: 0 10px 15px var(--theme-shadow-color); + + // Components + --theme-navbar-bg: #f8f9fa; + --theme-island-bg: var(--theme-surface); + --theme-input-bg: var(--theme-surface); + --theme-button-bg: var(--theme-primary); + --theme-button-text: var(--theme-primary-text); + + // Charts + --theme-chart-grid: var(--color-neutral-90); + --theme-chart-text: var(--theme-text-secondary); +} + +// Dark theme +:root[data-theme="dark"] { + // Backgrounds + --theme-background: var(--color-neutral-10); + --theme-surface: var(--color-neutral-15); + --theme-surface-variant: var(--color-neutral-18); + --theme-surface-hover: var(--color-neutral-20); + + // Text + --theme-text-primary: var(--color-neutral-90); + --theme-text-secondary: var(--color-neutral-75); + --theme-text-tertiary: var(--color-neutral-60); + --theme-text-disabled: var(--color-neutral-40); + + // Borders + --theme-border: var(--color-neutral-25); + --theme-border-variant: var(--color-neutral-20); + --theme-border-hover: var(--color-neutral-35); + + // Primary colors + --theme-primary: var(--color-primary-60); + --theme-primary-hover: var(--color-primary-65); + --theme-primary-surface: var(--color-primary-20); + --theme-primary-text: var(--color-neutral-10); + + // Secondary colors + --theme-secondary: var(--color-secondary-60); + --theme-secondary-hover: var(--color-secondary-65); + --theme-secondary-surface: var(--color-secondary-20); + + // Status colors + --theme-success: var(--color-success-60); + --theme-warning: var(--color-warning-60); + --theme-error: var(--color-error-60); + --theme-info: var(--color-info-60); + + // Shadows + --theme-shadow-color: rgba(0, 0, 0, 0.3); + --theme-shadow-sm: 0 1px 2px var(--theme-shadow-color); + --theme-shadow-md: 0 4px 6px var(--theme-shadow-color); + --theme-shadow-lg: 0 10px 15px var(--theme-shadow-color); + + // Components + --theme-navbar-bg: rgba(10, 25, 47, 0.85); + --theme-island-bg: var(--theme-surface); + --theme-input-bg: var(--color-neutral-20); + --theme-button-bg: var(--theme-primary); + --theme-button-text: var(--theme-primary-text); + + // Charts + --theme-chart-grid: var(--color-neutral-25); + --theme-chart-text: var(--theme-text-secondary); +} + +// Helper functions +@function getThemeColor($name) { + @return var(--theme-#{$name}); +} + +@function getColor($name, $lightness) { + @return var(--color-#{$name}-#{$lightness}); +} + +// Export for use in other files +:export { + theme: true; +} diff --git a/ethernet-view/src/styles/fonts.scss b/ethernet-view/src/styles/fonts.scss new file mode 100644 index 000000000..e6900a754 --- /dev/null +++ b/ethernet-view/src/styles/fonts.scss @@ -0,0 +1,3 @@ +@forward "/src/assets/fonts/Inter/Inter.scss"; +@forward "/src/assets/fonts/Consolas/Consolas.scss"; +@forward "/src/assets/fonts/NotoColorEmoji/NotoColorEmoji.scss"; diff --git a/ethernet-view/src/styles/globalOverride.scss b/ethernet-view/src/styles/globalOverride.scss new file mode 100644 index 000000000..7bf719ae4 --- /dev/null +++ b/ethernet-view/src/styles/globalOverride.scss @@ -0,0 +1,64 @@ +@use "src/styles/styles"; +@use "src/styles/colors" as colors; + +input { + margin: 0; +} + +td { + padding: 0; +} + +ul { + padding-left: 0; + margin: 0; +} + +hr { + margin-top: 0rem; + margin-bottom: 0rem; + height: 1px; + border: none; + background-color: colors.getThemeColor("border"); +} + +code { + @include styles.alternate-code-text; + font-weight: bold; + background-color: colors.getThemeColor("surface-variant"); + padding: 0.1rem 0.2rem; + border-radius: 0.3rem; +} + +//If it's only :root, font-size changes the base rem. If it's :root *, it affects everything, skipping inheritance. +:root > * { + box-sizing: border-box; + @include styles.normal-text; + scrollbar-track-color: transparent; + scrollbar-color: colors.getThemeColor("text-tertiary") none; + user-select: none; +} + +:root > * input { + outline: none; + border-style: solid; +} + +::-webkit-scrollbar { + width: 7px; + height: 7px; + background-color: colors.getThemeColor("surface"); +} + +::-webkit-scrollbar-track { + background-color: transparent; +} + +::-webkit-scrollbar-thumb { + background: colors.getThemeColor("border"); + border-radius: 1rem; +} + +::-webkit-scrollbar-thumb:hover { + background: colors.getThemeColor("border-hover"); +} diff --git a/ethernet-view/src/styles/styles.scss b/ethernet-view/src/styles/styles.scss new file mode 100644 index 000000000..a7b6171a9 --- /dev/null +++ b/ethernet-view/src/styles/styles.scss @@ -0,0 +1,123 @@ +@use "sass:color"; +@use "./colors.scss" as colors; + +// COLORS (now using theme system) +$background-color: colors.getThemeColor("background"); +$title-color: colors.getThemeColor("text-primary"); +$normal-text-color: colors.getThemeColor("text-primary"); +$alternate-text-color: colors.getThemeColor("text-secondary"); +$orange: colors.getColor("secondary", 50); +$blue: colors.getColor("primary", 50); +$base-color: colors.getColor("primary", 50); + +$dark-normal-text-color: colors.getThemeColor("text-secondary"); + +@function getColor($name, $lightness) { + @return colors.getColor($name, $lightness); +} + +// FONTS +$sans-font: Inter; +$code-font: Consolas, Cascadia Code, Cascadia Mono, Monospace; +$alternate-code-font: Consolas, Monospace; + +// FONT-SIZE +$title-font-size: 1.9rem; +$normal-font-size: 1rem; +$small-font-size: x-small; + +// FONT-WEIGHT +$bold-font-weight: 700; +$normal-font-weight: 400; + +// PADDING +$large-padding: 2rem; +$normal-padding: 1.2rem; + +// BORDER-RADIUS +$large-border-radius: 1rem; +$normal-border-radius: 0.2rem; + +// BORDER-WIDTH +$normal-border-width: 1px; + +// TRANSITIONS +$normal-transition-time: 0.08s; +$opacity-transition: opacity $normal-transition-time linear; +$background-color-transition: background-color $normal-transition-time linear; + +// MIXINS +@mixin code-text { + font-family: $code-font; + font-size: $normal-font-size; + color: $normal-text-color; +} + +@mixin alternate-code-text { + font-family: $alternate-code-font; + font-size: $normal-font-size; + color: $alternate-text-color; +} + +@mixin title-text { + font-family: $sans-font; + font-size: $title-font-size; + color: $title-color; + font-weight: $bold-font-weight; +} + +@mixin normal-text { + font-family: $sans-font; + font-size: $normal-font-size; + color: $alternate-text-color; + font-weight: $normal-font-weight; +} + +@mixin subtitle-text { + font-family: $code-font; + font-size: $normal-font-size; + font-style: italic; + color: colors.getThemeColor("text-tertiary"); +} + +@mixin tab-text { + font-family: $sans-font; + font-size: $normal-font-size; + font-weight: 500; + color: $title-color; +} + +@mixin inherit-text { + font-family: inherit; + font-size: inherit; + color: inherit; + font-weight: inherit; +} + +@mixin undraggable { + user-drag: none; + user-select: none; + -moz-user-select: none; + -webkit-user-drag: none; + -webkit-user-select: none; + -ms-user-select: none; +} + +@mixin shadow { + box-shadow: colors.getThemeColor("shadow-md"); +} + +// CLASSES + +// FUNCTIONS +@function transparency($color, $transparency) { + @return color.adjust($color, $alpha: $transparency); +} + +@function lightness($color, $lightness) { + @return color.adjust($color, $lightness: $lightness); +} + +@function saturation($color, $saturation) { + @return color.adjust($color, $saturation: $saturation); +} diff --git a/ethernet-view/src/types/ConfigData.ts b/ethernet-view/src/types/ConfigData.ts new file mode 100644 index 000000000..71b81fb0d --- /dev/null +++ b/ethernet-view/src/types/ConfigData.ts @@ -0,0 +1,38 @@ +export type ConfigData = { + vehicle: { + boards: string[]; + }; + adj: { + branch: string; + }; + network: { + manual: boolean; + }; + transport: { + propagate_fault: boolean; + }; + tcp: { + backoff_min_ms: number; + backoff_max_ms: number; + backoff_multiplier: number; + max_retries: number; + connection_timeout_ms: number; + keep_alive_ms: number; + }; + blcu: { + ip: string; + download_order_id: number; + upload_order_id: number; + }; + tftp: { + block_size: number; + retries: number; + timeout_ms: number; + backoff_factor: number; + enable_progress: boolean; + }; + logging: { + time_unit: string; + logging_path: string; + }; +}; diff --git a/ethernet-view/src/utils/array.ts b/ethernet-view/src/utils/array.ts new file mode 100644 index 000000000..a62cbb9c2 --- /dev/null +++ b/ethernet-view/src/utils/array.ts @@ -0,0 +1,12 @@ +export function mustFindIndex( + elements: T[], + predicate: (element: T) => boolean +): number { + const index = elements.findIndex((element) => predicate(element)); + + if (index == -1) { + console.error("element not found"); + } + + return index; +} diff --git a/ethernet-view/src/utils/color.ts b/ethernet-view/src/utils/color.ts new file mode 100644 index 000000000..d8212daf3 --- /dev/null +++ b/ethernet-view/src/utils/color.ts @@ -0,0 +1,66 @@ +import { clamp } from "utils/math"; + +export type HSLAColor = { + h: number; + s: number; + l: number; + a: number; +}; + +type RGBAColor = { + r: number; + g: number; + b: number; + a: number; +}; + +export function hslaToHex({ h, s, l, a }: HSLAColor): RGBAColor { + l /= 100; + const a2 = (s * Math.min(l, 1 - l)) / 100; + const f = (n: number) => { + const k = (n + h / 30) % 12; + const color = l - a2 * Math.max(Math.min(k - 3, 9 - k, 1), -1); + return Math.round(255 * color) + .toString(16) + .padStart(2, "0"); // convert to Hex and prefix "0" if needed + }; + return { + r: parseInt(f(0)), + g: parseInt(f(8)), + b: parseInt(f(4)), + a: a, + }; +} + +export function getSofterHSLAColor( + { h, s, l, a }: HSLAColor, + lightnessOffset: number +): HSLAColor { + return { h, s, l: clamp(l + lightnessOffset, 0, 100), a: a } as HSLAColor; +} + +export function hslaToString({ h, s, l, a }: HSLAColor): string { + return `hsl(${h},${s}%,${l}%,${a})`; +} + +export function parseHSL(colorStr: string): HSLAColor { + const matches = colorStr + .replaceAll(" ", "") + .match(/hsl\((\d{1,3}),(\d{1,3})%,(\d{1,3})%\)/)!; + const h = parseInt(matches[1]); + const s = parseInt(matches[2]); + const l = parseInt(matches[3]); + + return { h, s, l, a: 1 }; +} + +export function lightenHSL(color: string, lOffset: number): string { + const matches = color + .replaceAll(" ", "") + .match(/hsl\((\d{1,3}),(\d{1,3})%,(\d{1,3})%\)/)!; + const h = matches[1]; + const s = matches[2]; + const l = matches[3]; + const newLightness = Math.min(parseInt(l) + lOffset, 100); + return `hsl(${h}, ${s}%, ${newLightness}%)`; +} \ No newline at end of file diff --git a/ethernet-view/src/utils/math.ts b/ethernet-view/src/utils/math.ts new file mode 100644 index 000000000..256e703e0 --- /dev/null +++ b/ethernet-view/src/utils/math.ts @@ -0,0 +1,29 @@ +export function getVectorLimits(vector: number[]): [number, number] { + let min = Infinity; + let max = -Infinity; + + vector.forEach((value) => { + min = value < min ? value : min; + max = value > max ? value : max; + }); + + return [min, max]; +} + +export function getMultipleVectorsLimits(vectors: number[][]) { + let [minOfLines, maxOfLines] = [Infinity, -Infinity]; + for (let vector of vectors) { + const [localMin, localMax] = getVectorLimits(vector); + if (localMin < minOfLines) { + minOfLines = localMin; + } + if (localMax > maxOfLines) { + maxOfLines = localMax; + } + } + return [minOfLines, maxOfLines]; +} + +export function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(min, value), max); +} diff --git a/ethernet-view/src/vite-env.d.ts b/ethernet-view/src/vite-env.d.ts new file mode 100644 index 000000000..866caf79b --- /dev/null +++ b/ethernet-view/src/vite-env.d.ts @@ -0,0 +1,19 @@ +/// +/// + +import type { ConfigData } from "./types/ConfigData"; + +interface ElectronAPI { + saveConfig: (config: ConfigData) => Promise; + getConfig: () => Promise; + importConfig: () => Promise; + selectFolder: () => Promise; +} + +declare global { + interface Window { + electronAPI?: ElectronAPI; + } +} + +export {}; diff --git a/ethernet-view/tsconfig.json b/ethernet-view/tsconfig.json new file mode 100644 index 000000000..7785f9244 --- /dev/null +++ b/ethernet-view/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "baseUrl": "src", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/ethernet-view/tsconfig.node.json b/ethernet-view/tsconfig.node.json new file mode 100644 index 000000000..9d31e2aed --- /dev/null +++ b/ethernet-view/tsconfig.node.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/ethernet-view/vite.config.ts b/ethernet-view/vite.config.ts new file mode 100644 index 000000000..78bed8cc4 --- /dev/null +++ b/ethernet-view/vite.config.ts @@ -0,0 +1,27 @@ +/// +import { defineConfig } from "vite"; +import path from "path"; +import react from "@vitejs/plugin-react"; +import tsconfigPaths from "vite-tsconfig-paths"; +import svgr from "vite-plugin-svgr"; + +// https://vitejs.dev/config/ +export default defineConfig({ + base: "./", // Add this line for relative paths + build: { + sourcemap: true, + outDir: "static", + minify: false, + }, + plugins: [react(), tsconfigPaths(), svgr()], + test: { + globals: true, + environment: "jsdom", + setupFiles: "./src/tests/setup.ts", + }, + resolve: { + alias: { + common: path.resolve(__dirname, "../common-front"), + }, + }, +}); diff --git a/frontend/README.md b/frontend/README.md index de434e676..1a9792b7f 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,234 +1,97 @@ # Frontend -> **Note:** For general setup, prerequisites, and root-level commands, see the [main README](../README.md). +This is a monorepo workspace managed with **pnpm** and **Turborepo**. -This directory contains the frontend workspace for the Hyperloop Control Station, built with React, TypeScript, and Vite. +## ⚠️ Important: Package Manager -## Architecture Overview +**You MUST use `pnpm` for this project.** Do not use `npm`, `yarn`, or `bun`. -The frontend is organized as 6 workspaces out of 9 in the whole monorepo, divided into 3 main areas: +The project is configured to enforce pnpm usage. If you don't have pnpm installed, install it first: -### Workspaces - -| Workspace | Description | -| :----------------------------------------------------------------- | :---------------------------------------------------- | -| `testing-view` | Primary telemetry testing and debugging interface | -| `competition-view` | Competition-focused UI (simplified view) | -| `frontend-kit/ui` or `@workspace/ui` | Component library built on shadcn/ui and Radix UI | -| `frontend-kit/core` or `@workspace/core` | Shared business logic, WebSocket utilities, and types | -| `frontend-kit/eslint-config` or `@workspace/eslint-config` | Common ESLint configurations | -| `frontend-kit/typescript-config` or `@workspace/typescript-config` | Common TypeScript configurations | - -## Key Technologies - -- **React 19** with TypeScript -- **Vite** for build tooling -- **Zustand** for state management -- **React Router** for navigation -- **Radix UI / shadcn/ui** for UI components -- **WebSocket** for real-time backend communication -- **@dnd-kit** for drag-and-drop functionality - -## State Management - -The application uses **Zustand** with a slice-based architecture, organized by feature domain: - -### Global Slices - -- `appSlice` - Application mode, settings, and configuration -- `connectionsSlice` - WebSockets connection statuses -- `telemetrySlice` - Real-time telemetry data buffer -- `messagesSlice` - System messages and logs -- `catalogSlice` - Static definitions for telemetry packets and commands - -### Feature Slices - -- **Workspace Feature** (`features/workspace`) - - `workspacesSlice` - Manages workspace layout - - `rightSidebarSlice` - UI state for the collapsible sidebar and its tabs -- **Charts Feature** (`features/charts`) - - `chartsSlice` - Manages chart instances, series configuration, and visualization settings -- **Filtering Feature** (`features/filtering`) - - `filteringSlice` - Manages active filters, search queries, and category selection - -### Workspace System - -Testing View supports multiple workspaces to organize different testing scenarios. Each workspace has: - -- Independent filters for commands and telemetry -- Separate chart configurations -- Isolated tab state and expanded items -- Persistent configuration - -## WebSocket Integration - -The frontend connects to the Go backend via WebSocket for real-time communication: - -- **Connection**: `useWebSocket` hook from `@workspace/core` -- **Topics**: Subscribe to specific data streams using `useTopic` - - `podData/update` - Telemetry data - - `connection/update` - Backend connection status - - `message/update` - System messages -- **Sending packets**: Send a message through websocket using `post` method from `socketService` - -Example: - -```tsx -import { useTopic, useWebSocket } from "@workspace/ui/hooks"; -import { socketService } from "@workspace/core"; - -const { isConnected } = useWebSocket(); - -useTopic("podData/update", (data) => { - // Handle telemetry data -}); - -socketService.post("order/send", ); +```bash +npm install -g pnpm ``` -## Component Library (@workspace/ui) +OR -The shared UI package provides: - -- shadcn/ui components (Button, Dialog, Dropdown, etc.) -- Custom components (Sidebar, Charts, Filters) -- Hooks (useWebSocket, useTopic, useLogger) -- Icons from Lucide React - -Import components from `@workspace/ui`: - -```tsx -import { Button, Dialog } from "@workspace/ui"; -import { Plus, Settings } from "@workspace/ui/icons"; +```bash +npm install --global corepack@latest +corepack enable pnpm ``` -## Development Patterns - -### Styling & Theming - -- **CSS Variables** for theming (defined in `globals.css`) -- **Tailwind CSS** for utility classes -- **Dark mode** support via CSS class toggling -- Multiple color schemes (default and pink) +For other cases, read: [Official pnpm installation guide](https://pnpm.io/installation) -### Adding Icons +## Getting Started -To add a new Lucide icon to the shared UI library: +All commands should be executed from the `frontend` folder. -1. Look up the icon on [Lucide Icons](https://lucide.dev/icons) -2. Find the **first category** listed for that icon -3. Add the import to the corresponding category file in `frontend-kit/ui/src/icons/` -4. If the category file doesn't exist, create it and add its export to `index.ts` -5. Keep the same alphabetical order as the icon categories +### Installation -**Example:** The `Axe` icon's first category is `Tools`, so you would add its import to `tools.ts`: +Install all dependencies: -```js -// frontend-kit/ui/src/icons/tools.ts -export { Axe, Hammer } from "lucide-react"; -``` - -## Project Structure - -``` -frontend/ -├── frontend-kit/ -│ ├── ui/ # Shared UI components -│ │ ├── src/components/ # React components -│ │ ├── src/hooks/ # Custom hooks -│ │ └── src/styles/ # Global styles -│ ├── core/ # Business logic -│ │ └── src/ # WebSocket, utilities, types -│ ├── eslint-config/ # ESLint configs -│ └── typescript-config/ # TS configs -├── testing-view/ -│ ├── src/ -│ │ ├── assets/ # Assets (images, gifs, etc.) -│ │ ├── components/ # Global UI components -│ │ ├── features/ # Components, hooks, types and store slices related to features -│ │ ├── layout/ # App layout -│ │ ├── pages/ # Route pages -│ │ ├── store/ # Global Zustand store slices -│ │ ├── hooks/ # Global custom hooks -│ │ ├── constants/ # Config and constants -│ │ ├── types/ # Global TypeScript types -│ │ ├── mocks/ # Mocks -│ │ └── lib/ # Utilities -│ └── public/ # Static assets -└── competition-view/ - └── src/ # Similar structure +```bash +pnpm install ``` -## Common Tasks +### Available Scripts -### Adding a Dependency to a Specific Workspace +Run these commands from the `frontend` folder: -Use pnpm's `--filter` flag (run from root or frontend directory): +- **`pnpm dev`** - Start development servers for all packages +- **`pnpm build`** - Build all packages +- **`pnpm lint`** - Lint all packages +- **`pnpm format`** - Format code using Prettier +- **`pnpm preview`** - Preview built versions (requires `pnpm build` run first) -```bash -pnpm add --filter testing-view -``` +### Installing Dependencies -### Running a Specific Workspace - -From root: +To add a new dependency to a specific project, use: ```bash -pnpm dev --filter testing-view +pnpm add --filter ``` -### Linting & Formatting +For example, to add a dependency to `testing-view`: ```bash -pnpm lint -pnpm format +pnpm add --filter testing-view ``` -## Testing - -- Test framework: **Vitest** -- Component testing: **@testing-library/react** - -Run tests: +To add a dev dependency: ```bash -pnpm test +pnpm add -D --filter ``` -## Build +### Installing shadcn/ui Components -Build all frontend packages: +To install shadcn/ui components, use `pnpm dlx` (pnpm's equivalent to npx) with the `-c` flag to specify the components configuration file location: ```bash -pnpm build +pnpm dlx shadcn@latest add -c frontend-kit/ui ``` -Preview production builds: +For example, to add a button component: ```bash -pnpm preview +pnpm dlx shadcn@latest add button -c frontend-kit/ui ``` -## Troubleshooting - -### WebSocket Connection Issues - -- Ensure backend is running on the expected port -- Check `.env.development` for `VITE_BACKEND_URL` configuration +The components will be installed in `frontend-kit/ui/src/components/shadcn/`. -### Store State Issues - -- Use Redux DevTools extension for debugging Zustand state -- Ensure getters return stable references (use constants for empty arrays/objects) +## Project Structure -### Component Re-render Issues +This monorepo contains: -- Use React DevTools Profiler to identify excessive re-renders -- Ensure Zustand selectors are properly scoped +- **`frontend-kit/`** - Shared UI components and utilities + - `ui/` - shadcn/ui components and custom components + - `core/` - Core utilities + - `esling-config/` - Shared ESLint configurations + - `typescript-config/` - Shared TypeScript configurations +- **`testing-view/`** - Testing application +- **`competition-view/`** - Competition view application -## Additional Resources +## Requirements -- [React Documentation](https://react.dev/) -- [Zustand Documentation](https://github.com/pmndrs/zustand) -- [shadcn/ui Documentation](https://ui.shadcn.com/) -- [Vite Documentation](https://vitejs.dev/) +- Node.js >= 20 +- pnpm >= 10.24.0 diff --git a/frontend/frontend-kit/core/package.json b/frontend/frontend-kit/core/package.json index bb358f5a3..75654f21c 100644 --- a/frontend/frontend-kit/core/package.json +++ b/frontend/frontend-kit/core/package.json @@ -14,12 +14,9 @@ "packageManager": "pnpm@10.24.0", "devDependencies": { "@workspace/eslint-config": "workspace:*", - "eslint": "^9.39.2" + "eslint": "^9.39.1" }, "exports": { ".": "./src/index.ts" - }, - "dependencies": { - "rxjs": "^7.8.2" } } diff --git a/frontend/frontend-kit/core/src/index.ts b/frontend/frontend-kit/core/src/index.ts index c1f7d87f3..c47f0b72c 100644 --- a/frontend/frontend-kit/core/src/index.ts +++ b/frontend/frontend-kit/core/src/index.ts @@ -1,4 +1 @@ -export * from "./logger"; -export * from "./minMaxDownsample"; -export * from "./types"; export * from "./websocket"; diff --git a/frontend/frontend-kit/core/src/logger.ts b/frontend/frontend-kit/core/src/logger.ts deleted file mode 100644 index b6ff4f42e..000000000 --- a/frontend/frontend-kit/core/src/logger.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { loggerColors } from "./loggerColors"; -import type { LoggerModule } from "./types"; - -/** - * Creates a logger for a given module - * @param module - the module to log for (it's just a colored string that will be shown between `[` and `]` before the message itself) - * @returns a logger object with `log`, `warn`, and `error` methods - */ -function createLogger(module: LoggerModule) { - const color = loggerColors[module]; - const prefix = `[${module.toUpperCase()}]`; - - return { - // It's important to use `bind` here to correctly display log file path and line number in the console - // Otherwise, console prints will just point to this file - log: console.log.bind(console, `${color}${prefix}${loggerColors.reset}`), - warn: console.warn.bind(console, `${color}${prefix}${loggerColors.reset}`), - error: console.error.bind( - console, - `${color}${prefix}${loggerColors.reset}`, - ), - }; -} - -/** - * Logger object with methods for each module - */ -export const logger = { - testingView: createLogger("testing-view"), - competitionView: createLogger("competition-view"), - core: createLogger("core"), - ui: createLogger("ui"), -}; diff --git a/frontend/frontend-kit/core/src/loggerColors.ts b/frontend/frontend-kit/core/src/loggerColors.ts deleted file mode 100644 index c99f7d68d..000000000 --- a/frontend/frontend-kit/core/src/loggerColors.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const loggerColors = { - "testing-view": "\x1b[36m", // Cyan - "competition-view": "\x1b[35m", // Magenta - core: "\x1b[33m", // Yellow - ui: "\x1b[32m", // Green - reset: "\x1b[0m", -}; diff --git a/frontend/frontend-kit/core/src/minMaxDownsample.ts b/frontend/frontend-kit/core/src/minMaxDownsample.ts deleted file mode 100644 index a0324c22f..000000000 --- a/frontend/frontend-kit/core/src/minMaxDownsample.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { type TelemetryPacket, type VariableValue } from "./types"; - -/** - * Helper to extract a numeric value for comparison. - * Handles both primitive numbers, booleans and { last, average } objects. - */ -const getNumericValue = ( - val: VariableValue | undefined, -): number | undefined => { - if (typeof val === "number") { - return val; - } - - if (typeof val === "boolean") { - return val ? 1 : 0; - } - - if ( - typeof val === "object" && - val !== null && - "last" in val && - "average" in val - ) { - return val.last; - } - - return undefined; -}; - -/** - * Downsamples a buffer of packets using the min-max algorithm.\ - * It considers only numeric variables, booleans and { last, average } object variables. - * - * The idea is to reduce the number of packets in the buffer by keeping only the min and max packets - * to prevent the app from freezing when there are too many packets. (Usually happens on start) - * - * @param buffer - array of packets to downsample, should contain at least 2 elements - * @returns downsampled buffer with only min and max packets from the original buffer (in chronological order) - */ -export const minMaxDownsample = (buffer: TelemetryPacket[]) => { - if (buffer.length < 2) return buffer; - - let minIdx = 0; - let maxIdx = 0; - - buffer.forEach((packet, i) => { - const measurements = packet.measurementUpdates || {}; - - // At the beginning the initial champion is the first variable in the packet - const firstKey = Object.keys(measurements)[0]; - if (!firstKey) return; - - const rawVal = measurements[firstKey]; - const val = getNumericValue(rawVal); - - const minVal = getNumericValue( - buffer[minIdx]?.measurementUpdates[firstKey], - ); - const maxVal = getNumericValue( - buffer[maxIdx]?.measurementUpdates[firstKey], - ); - - // Compare local min and max with the global champions - // If one of them is undefined, use Infinity or -Infinity respectively - if (val !== undefined) { - if (val < (minVal ?? Infinity)) minIdx = i; - if (val > (maxVal ?? -Infinity)) maxIdx = i; - } - }); - - // Return them in chronological order to maintain X-axis integrity - const result = - minIdx < maxIdx - ? [buffer[minIdx], buffer[maxIdx]] - : [buffer[maxIdx], buffer[minIdx]]; - - return result; -}; diff --git a/frontend/frontend-kit/core/src/types.ts b/frontend/frontend-kit/core/src/types.ts deleted file mode 100644 index e0346cc78..000000000 --- a/frontend/frontend-kit/core/src/types.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Options for the `onTopic` method of the `SocketService` class. - */ -export interface TopicOptions { - /** The downsampling method to use. */ - downsample?: "min-max" | "none"; - - /** The throttle time in milliseconds. */ - throttle?: number; -} - -/** - * The value of a variable in a telemetry packet. - */ -export type VariableValue = - | { last: number; average: number } - | boolean - | string - | number; - -/** - * The variables of a telemetry packet. - */ -export type Variables = Record; - -/** - * A telemetry packet that arrives in high frequency.\ - * Don't confuse it with the `TelemetryCatalogItem` type. - */ -export interface TelemetryPacket { - count: number; - cycleTime: number; - hexValue: string; - id: number; - measurementUpdates: Variables; -} - -/** - * The modules that can be logged to. Used for the `logger` object. - */ -export type LoggerModule = "testing-view" | "competition-view" | "core" | "ui"; diff --git a/frontend/frontend-kit/core/src/websocket.ts b/frontend/frontend-kit/core/src/websocket.ts deleted file mode 100644 index 649818f1f..000000000 --- a/frontend/frontend-kit/core/src/websocket.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { - asyncScheduler, - BehaviorSubject, - bufferTime, - catchError, - concatMap, - EMPTY, - filter, - from, - map, - Observable, - ReplaySubject, - shareReplay, - Subject, - switchMap, - throttleTime, -} from "rxjs"; -import { webSocket, WebSocketSubject } from "rxjs/webSocket"; -import { logger } from "./logger"; -import { minMaxDownsample } from "./minMaxDownsample"; -import { type TopicOptions } from "./types"; - -/** - * Service for connecting to the WebSocket server and subscribing to topics - */ -class SocketService { - /** - * Singleton instance that holds the WebSocket subject - */ - private socketSource$ = new ReplaySubject>(1); - - /** - * Subject that holds the status of the WebSocket connection. - */ - public status$ = new BehaviorSubject< - "connected" | "disconnected" | "connecting" - >("disconnected"); - - /** - * Subject that holds the error of the WebSocket connection. - * - * The idea is to emit only one error event for each error.\ - * Without this subject error gets duplicated because of shareReplay operator. - */ - public error$ = new Subject(); - - /** - * Observable that emits the messages from the WebSocket server. - */ - public messages$: Observable = this.socketSource$.pipe( - switchMap((socket) => - socket.pipe( - catchError((err) => { - this.error$.next(err); - return EMPTY; - }), - ), - ), - shareReplay(1), - ); - - /** - * Disposable WebSocket connection object. Lives only as long as the connection is open. - */ - private ws: WebSocketSubject | null = null; - - /** - * Connects to the WebSocket server by creating a new connection object and pushing it to `socketSource$`. - * @param port - the port to connect to. Defaults to 4000. - */ - connect(port: number = 4000) { - if (this.ws) return; - - logger.core.log("Connecting to WebSocket..."); - this.status$.next("connecting"); - - this.ws = webSocket({ - url: `ws://127.0.0.1:${port}/backend`, - deserializer: (e) => JSON.parse(e.data), - openObserver: { - next: () => { - this.status$.next("connected"); - logger.core.log("WebSocket connected"); - }, - }, - }); - - this.ws.subscribe({ - error: (err) => { - logger.core.error("WebSocket Error:", err); - this.error$.next(err); - - this.status$.next("disconnected"); - this.cleanup(); - }, - complete: () => { - logger.core.log("WebSocket Connection Closed. Cleaning up..."); - this.cleanup(); - }, - }); - - this.socketSource$.next(this.ws); - } - - /** - * Cleans up the WebSocket connection by setting the connection object to null and updating the status to "disconnected". - */ - private cleanup() { - this.ws = null; - this.status$.next("disconnected"); - } - - /** - * Creates an observable that emits the messages from the WebSocket server for a given topic. - * @param topic - the topic to subscribe to. - * @param options - options for the observable. - - * Downsampling and throttling are supported. - * In case of downsampling, throttling option is used as the buffering time and defaults to 100ms. - * @returns an observable that emits the messages from the WebSocket server for a given topic. - */ - onTopic(topic: string, options: TopicOptions = {}) { - let pipe$ = this.messages$.pipe( - filter((msg) => msg.topic === topic), - map((msg) => msg.payload), - ); - - // Apply downsampling if requested - if (options.downsample == "min-max") { - pipe$ = pipe$.pipe( - // Apply buffering - bufferTime(options.throttle ?? 100), - filter((buffer) => buffer.length > 0), - concatMap((buffer) => { - if (buffer.length <= 2) return from(buffer); - - const result = minMaxDownsample(buffer); - logger.core.log( - `[Downsample] ${topic}: ${buffer.length} in -> ${result.length} out`, - ); - return from(result); - }), - ); - } - - // Apply throttling if requested - if (options.throttle) { - pipe$ = pipe$.pipe(throttleTime(options.throttle, asyncScheduler)); - } - - return pipe$; - } - - /** - * Posts a message to the WebSocket server. If the connection is not established, an error is logged and the message is not sent. - * @param topic - the topic to post to. - * @param payload - the payload to post. - * // TODO: reference payloads definition file - */ - post(topic: string, payload: any) { - if (!this.ws) { - logger.core.error("Cannot post: Socket not connected."); - return; - } - this.ws.next({ topic, payload }); - } - - /** - * Subscribes to the error subject. - * @param callback - the callback to call when an error occurs. - * @returns a subscription to the error subject. - */ - onError(callback: (err: Error) => void) { - return this.error$.subscribe(callback); - } -} - -export const socketService = new SocketService(); diff --git a/frontend/frontend-kit/core/src/websocket/connect.ts b/frontend/frontend-kit/core/src/websocket/connect.ts new file mode 100644 index 000000000..ac6ea00fc --- /dev/null +++ b/frontend/frontend-kit/core/src/websocket/connect.ts @@ -0,0 +1,3 @@ +export function connect() { + console.log("[TEST] connecting to websocket"); +} diff --git a/frontend/frontend-kit/core/src/websocket/index.ts b/frontend/frontend-kit/core/src/websocket/index.ts new file mode 100644 index 000000000..2e22528f7 --- /dev/null +++ b/frontend/frontend-kit/core/src/websocket/index.ts @@ -0,0 +1 @@ +export * from "./connect"; diff --git a/frontend/frontend-kit/esling-config/package.json b/frontend/frontend-kit/esling-config/package.json index dfb4d11d7..8216a4d86 100644 --- a/frontend/frontend-kit/esling-config/package.json +++ b/frontend/frontend-kit/esling-config/package.json @@ -12,18 +12,18 @@ "./vite": "./vite.js" }, "devDependencies": { - "@eslint/js": "^9.39.2", - "@typescript-eslint/eslint-plugin": "^8.54.0", - "@typescript-eslint/parser": "^8.54.0", - "eslint": "^9.39.2", - "eslint-config-prettier": "^10.1.8", + "@typescript-eslint/eslint-plugin": "^8.24.1", + "@typescript-eslint/parser": "^8.24.1", + "eslint": "^9.20.1", + "@eslint/js": "^9.20.1", + "eslint-config-prettier": "^9.1.0", "eslint-plugin-only-warn": "^1.1.0", - "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.5.0", - "eslint-plugin-turbo": "^2.8.3", - "globals": "^17.3.0", - "typescript": "^5.9.3", - "typescript-eslint": "^8.54.0" + "eslint-plugin-react": "^7.37.4", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.19", + "eslint-plugin-turbo": "^2.4.2", + "globals": "^15.15.0", + "typescript": "^5.7.3", + "typescript-eslint": "^8.24.1" } } diff --git a/frontend/frontend-kit/ui/components.json b/frontend/frontend-kit/ui/components.json index 29e8e6d99..814866f90 100644 --- a/frontend/frontend-kit/ui/components.json +++ b/frontend/frontend-kit/ui/components.json @@ -11,9 +11,9 @@ }, "iconLibrary": "lucide", "aliases": { - "components": "@workspace/ui/components", - "hooks": "@workspace/ui/hooks/shadcn", - "lib": "@workspace/ui/lib", + "components": "@/components", + "hooks": "@/hooks", + "lib": "@/lib", "utils": "@workspace/ui/lib/utils", "ui": "@workspace/ui/components/shadcn" } diff --git a/frontend/frontend-kit/ui/package.json b/frontend/frontend-kit/ui/package.json index 9c26d70ce..08c9a6a50 100644 --- a/frontend/frontend-kit/ui/package.json +++ b/frontend/frontend-kit/ui/package.json @@ -8,42 +8,28 @@ "lint": "eslint ." }, "dependencies": { - "@radix-ui/react-checkbox": "^1.3.3", - "@radix-ui/react-collapsible": "^1.1.12", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.1.16", - "@radix-ui/react-label": "^2.1.8", - "@radix-ui/react-popover": "^1.1.15", - "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-separator": "^1.1.8", - "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-tabs": "^1.1.13", - "@radix-ui/react-tooltip": "^1.2.8", - "@workspace/core": "workspace:*", + "@radix-ui/react-slot": "^1.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "lucide-react": "^0.563.0", - "next-themes": "^0.4.6", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "react-resizable-panels": "^4.6.0", - "rxjs": "^7.8.2", - "tailwind-merge": "^3.4.0", - "tw-animate-css": "^1.4.0", - "zod": "^4.3.6", - "zustand": "^5.0.11" + "lucide-react": "^0.475.0", + "next-themes": "^0.4.4", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwind-merge": "^3.0.1", + "tw-animate-css": "^1.2.4", + "zod": "^3.24.2" }, "devDependencies": { - "@tailwindcss/postcss": "^4.1.18", - "@turbo/gen": "^2.8.3", - "@types/node": "^25.2.0", - "@types/react": "^19.2.11", - "@types/react-dom": "^19.2.3", + "@tailwindcss/postcss": "^4.0.8", + "@turbo/gen": "^2.4.2", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", "@workspace/eslint-config": "workspace:*", "@workspace/typescript-config": "workspace:*", - "eslint": "^9.39.2", - "tailwindcss": "^4.1.18", - "typescript": "^5.9.3" + "eslint": "^9.39.1", + "tailwindcss": "^4.0.8", + "typescript": "^5.7.3" }, "exports": { ".": "./src/index.ts", @@ -54,8 +40,6 @@ "./hooks": "./src/hooks/index.ts", "./lib/*": "./src/lib/*.ts", "./components/*": "./src/components/*.tsx", - "./hooks/*": "./src/hooks/*.ts", - "./store": "./src/store/index.ts", - "./icons": "./src/icons/index.ts" + "./hooks/*": "./src/hooks/*.ts" } } diff --git a/frontend/frontend-kit/ui/src/components/shadcn/button.tsx b/frontend/frontend-kit/ui/src/components/shadcn/button.tsx index b25fb8e68..35c86efde 100644 --- a/frontend/frontend-kit/ui/src/components/shadcn/button.tsx +++ b/frontend/frontend-kit/ui/src/components/shadcn/button.tsx @@ -1,6 +1,6 @@ +import * as React from "react"; import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; -import * as React from "react"; import { cn } from "@workspace/ui/lib/utils"; @@ -13,11 +13,11 @@ const buttonVariants = cva( destructive: "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: - "border bg-background text-foreground shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: - "text-foreground hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "text-primary underline-offset-4 hover:underline", }, size: { diff --git a/frontend/frontend-kit/ui/src/components/shadcn/card.tsx b/frontend/frontend-kit/ui/src/components/shadcn/card.tsx deleted file mode 100644 index 2e7392a65..000000000 --- a/frontend/frontend-kit/ui/src/components/shadcn/card.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import * as React from "react" - -import { cn } from "@workspace/ui/lib/utils" - -function Card({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardTitle({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardDescription({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardAction({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardContent({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -export { - Card, - CardHeader, - CardFooter, - CardTitle, - CardAction, - CardDescription, - CardContent, -} diff --git a/frontend/frontend-kit/ui/src/components/shadcn/checkbox.tsx b/frontend/frontend-kit/ui/src/components/shadcn/checkbox.tsx deleted file mode 100644 index bca5ecca4..000000000 --- a/frontend/frontend-kit/ui/src/components/shadcn/checkbox.tsx +++ /dev/null @@ -1,32 +0,0 @@ -"use client" - -import * as React from "react" -import * as CheckboxPrimitive from "@radix-ui/react-checkbox" -import { CheckIcon } from "lucide-react" - -import { cn } from "@workspace/ui/lib/utils" - -function Checkbox({ - className, - ...props -}: React.ComponentProps) { - return ( - - - - - - ) -} - -export { Checkbox } diff --git a/frontend/frontend-kit/ui/src/components/shadcn/collapsible.tsx b/frontend/frontend-kit/ui/src/components/shadcn/collapsible.tsx deleted file mode 100644 index ae9fad04a..000000000 --- a/frontend/frontend-kit/ui/src/components/shadcn/collapsible.tsx +++ /dev/null @@ -1,33 +0,0 @@ -"use client" - -import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" - -function Collapsible({ - ...props -}: React.ComponentProps) { - return -} - -function CollapsibleTrigger({ - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function CollapsibleContent({ - ...props -}: React.ComponentProps) { - return ( - - ) -} - -export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/frontend/frontend-kit/ui/src/components/shadcn/dialog.tsx b/frontend/frontend-kit/ui/src/components/shadcn/dialog.tsx deleted file mode 100644 index b905322cf..000000000 --- a/frontend/frontend-kit/ui/src/components/shadcn/dialog.tsx +++ /dev/null @@ -1,143 +0,0 @@ -"use client" - -import * as React from "react" -import * as DialogPrimitive from "@radix-ui/react-dialog" -import { XIcon } from "lucide-react" - -import { cn } from "@workspace/ui/lib/utils" - -function Dialog({ - ...props -}: React.ComponentProps) { - return -} - -function DialogTrigger({ - ...props -}: React.ComponentProps) { - return -} - -function DialogPortal({ - ...props -}: React.ComponentProps) { - return -} - -function DialogClose({ - ...props -}: React.ComponentProps) { - return -} - -function DialogOverlay({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DialogContent({ - className, - children, - showCloseButton = true, - ...props -}: React.ComponentProps & { - showCloseButton?: boolean -}) { - return ( - - - - {children} - {showCloseButton && ( - - - Close - - )} - - - ) -} - -function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function DialogTitle({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DialogDescription({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -export { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogOverlay, - DialogPortal, - DialogTitle, - DialogTrigger, -} diff --git a/frontend/frontend-kit/ui/src/components/shadcn/dropdown-menu.tsx b/frontend/frontend-kit/ui/src/components/shadcn/dropdown-menu.tsx deleted file mode 100644 index 6c011e181..000000000 --- a/frontend/frontend-kit/ui/src/components/shadcn/dropdown-menu.tsx +++ /dev/null @@ -1,257 +0,0 @@ -"use client" - -import * as React from "react" -import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" -import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" - -import { cn } from "@workspace/ui/lib/utils" - -function DropdownMenu({ - ...props -}: React.ComponentProps) { - return -} - -function DropdownMenuPortal({ - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DropdownMenuTrigger({ - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DropdownMenuContent({ - className, - sideOffset = 4, - ...props -}: React.ComponentProps) { - return ( - - - - ) -} - -function DropdownMenuGroup({ - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DropdownMenuItem({ - className, - inset, - variant = "default", - ...props -}: React.ComponentProps & { - inset?: boolean - variant?: "default" | "destructive" -}) { - return ( - - ) -} - -function DropdownMenuCheckboxItem({ - className, - children, - checked, - ...props -}: React.ComponentProps) { - return ( - - - - - - - {children} - - ) -} - -function DropdownMenuRadioGroup({ - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DropdownMenuRadioItem({ - className, - children, - ...props -}: React.ComponentProps) { - return ( - - - - - - - {children} - - ) -} - -function DropdownMenuLabel({ - className, - inset, - ...props -}: React.ComponentProps & { - inset?: boolean -}) { - return ( - - ) -} - -function DropdownMenuSeparator({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DropdownMenuShortcut({ - className, - ...props -}: React.ComponentProps<"span">) { - return ( - - ) -} - -function DropdownMenuSub({ - ...props -}: React.ComponentProps) { - return -} - -function DropdownMenuSubTrigger({ - className, - inset, - children, - ...props -}: React.ComponentProps & { - inset?: boolean -}) { - return ( - - {children} - - - ) -} - -function DropdownMenuSubContent({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -export { - DropdownMenu, - DropdownMenuPortal, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuLabel, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuSub, - DropdownMenuSubTrigger, - DropdownMenuSubContent, -} diff --git a/frontend/frontend-kit/ui/src/components/shadcn/field.tsx b/frontend/frontend-kit/ui/src/components/shadcn/field.tsx deleted file mode 100644 index 7d7b87450..000000000 --- a/frontend/frontend-kit/ui/src/components/shadcn/field.tsx +++ /dev/null @@ -1,248 +0,0 @@ -"use client" - -import { useMemo } from "react" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@workspace/ui/lib/utils" -import { Label } from "@workspace/ui/components/shadcn/label" -import { Separator } from "@workspace/ui/components/shadcn/separator" - -function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { - return ( -
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", - className - )} - {...props} - /> - ) -} - -function FieldLegend({ - className, - variant = "legend", - ...props -}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { - return ( - - ) -} - -function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { - return ( -
[data-slot=field-group]]:gap-4", - className - )} - {...props} - /> - ) -} - -const fieldVariants = cva( - "group/field flex w-full gap-3 data-[invalid=true]:text-destructive", - { - variants: { - orientation: { - vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"], - horizontal: [ - "flex-row items-center", - "[&>[data-slot=field-label]]:flex-auto", - "has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", - ], - responsive: [ - "flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto", - "@md/field-group:[&>[data-slot=field-label]]:flex-auto", - "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", - ], - }, - }, - defaultVariants: { - orientation: "vertical", - }, - } -) - -function Field({ - className, - orientation = "vertical", - ...props -}: React.ComponentProps<"div"> & VariantProps) { - return ( -
- ) -} - -function FieldContent({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function FieldLabel({ - className, - ...props -}: React.ComponentProps) { - return ( -